From 72d9058cc60057fcd8e330da12e0088e9bd18197 Mon Sep 17 00:00:00 2001 From: Multica Eve Date: Fri, 23 Oct 2026 07:40:05 +0800 Subject: [PATCH] feat(telemetry): add visiting and delegating member counts (MUL-8161) (#9435) * feat(telemetry): report visiting and delegating member counts in self-host snapshot v2 (MUL-8161) Co-authored-by: multica-agent * fix(telemetry): count delegating members from a human-action ledger, drop v1 fallback (MUL-8161) - member_delegation_day records (member, UTC day) at the human action itself, so retries, system wakeups and creator-fallback runs never count, and a comment folded into a queued run keeps both humans and their own days. - Activity read failures now fail collection and use the existing retry instead of silently sending a v1 snapshot. Co-authored-by: multica-agent * fix(telemetry): record create-with-assignee, honor DO_NOT_TRACK, build ledger PK concurrently (MUL-8161) - A member creating an issue assigned to an agent or squad now records a delegation day, on both the immediate and deferred create paths. - TaskService.RecordMemberActivity gates the ledger and is set from the startup telemetry config, so DO_NOT_TRACK stops collection too. - member_delegation_day's primary key is built with CREATE UNIQUE INDEX CONCURRENTLY + PRIMARY KEY USING INDEX (589-591), with cleanup registered. Co-authored-by: multica-agent --------- Co-authored-by: multica-agent --- SELF_HOSTING.md | 4 +- .../content/docs/environment-variables.fr.mdx | 2 +- .../content/docs/environment-variables.ja.mdx | 2 +- .../content/docs/environment-variables.ko.mdx | 2 +- .../content/docs/environment-variables.mdx | 2 +- .../content/docs/environment-variables.zh.mdx | 2 +- apps/web/features/landing/i18n/en.ts | 6 +- apps/web/features/landing/i18n/ja.ts | 4 +- apps/web/features/landing/i18n/ko.ts | 4 +- apps/web/features/landing/i18n/zh.ts | 4 +- server/cmd/migrate/main.go | 1 + server/cmd/server/main.go | 3 + server/internal/attribution/attribution.go | 17 ++ .../internal/attribution/attribution_test.go | 30 +++ server/internal/handler/comment.go | 3 + .../handler/workspace_delete_manifest_test.go | 1 + .../internal/selfhosttelemetry/collector.go | 53 ++++ server/internal/selfhosttelemetry/event.go | 32 ++- .../internal/selfhosttelemetry/event_test.go | 31 ++- .../selfhosttelemetry/postgres_test.go | 103 ++++++++ server/internal/service/autopilot.go | 2 + server/internal/service/issue.go | 15 ++ .../selfhost_telemetry_activity_test.go | 226 ++++++++++++++++++ server/internal/service/task.go | 36 +++ .../589_member_delegation_day.down.sql | 1 + .../589_member_delegation_day.up.sql | 16 ++ ...ember_delegation_day_unique_index.down.sql | 1 + ..._member_delegation_day_unique_index.up.sql | 1 + ...member_delegation_day_primary_key.down.sql | 1 + ...1_member_delegation_day_primary_key.up.sql | 1 + .../pkg/db/generated/member_delegation.sql.go | 32 +++ server/pkg/db/generated/models.go | 5 + server/pkg/db/queries/member_delegation.sql | 8 + 33 files changed, 630 insertions(+), 21 deletions(-) create mode 100644 server/internal/service/selfhost_telemetry_activity_test.go create mode 100644 server/migrations/589_member_delegation_day.down.sql create mode 100644 server/migrations/589_member_delegation_day.up.sql create mode 100644 server/migrations/590_member_delegation_day_unique_index.down.sql create mode 100644 server/migrations/590_member_delegation_day_unique_index.up.sql create mode 100644 server/migrations/591_member_delegation_day_primary_key.down.sql create mode 100644 server/migrations/591_member_delegation_day_primary_key.up.sql create mode 100644 server/pkg/db/generated/member_delegation.sql.go create mode 100644 server/pkg/db/queries/member_delegation.sql diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 636d74d30..63909ee37 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -14,9 +14,9 @@ Each user who runs AI agents locally also installs the **`multica` CLI** and run ## Anonymous Deployment Telemetry -The self-hosted API server sends one first-party, deployment-level anonymous snapshot per UTC day to the fixed Multica endpoint `https://telemetry.multica.ai/v1/telemetry/events`. This helps maintainers understand which release versions are in use, deployment-size ranges, and aggregate run volume. The endpoint is compiled into the server and cannot be redirected through configuration. +The self-hosted API server sends one first-party, deployment-level anonymous snapshot per UTC day to the fixed Multica endpoint `https://telemetry.multica.ai/v1/telemetry/events`. This helps maintainers understand which release versions are in use, deployment-size ranges, aggregate run volume, and how many members actively use a deployment. The endpoint is compiled into the server and cannot be redirected through configuration. -The snapshot contains only the release version; bucketed counts of workspaces, distinct human members, active agents, and daemons seen in the previous 24 hours; and aggregate counts of runs started, completed, failed, and cancelled in that window. It does not include names, email addresses or domains, IP addresses, business IDs, host/device details, repository or organization details, model/plugin data, prompts or output, comments or chats, paths, token/cost data, credentials, errors, stacks, or logs. This telemetry is not used for billing, licensing, authentication, or security decisions. +The snapshot contains only the release version; bucketed counts of workspaces, distinct human members, active agents, and daemons seen in the previous 24 hours; aggregate counts of runs started, completed, failed, and cancelled in that window; and bucketed counts of members who opened the signed-in web or desktop app, and of members who personally started an agent run (assign, mention, comment, chat, manual run, or rerun — not autopilot or agent-to-agent delegation), in the previous 1, 7, and 28 complete UTC days. Members are counted per deployment and are never identified. It does not include names, email addresses or domains, IP addresses, business IDs, host/device details, repository or organization details, model/plugin data, prompts or output, comments or chats, paths, token/cost data, credentials, errors, stacks, or logs. This telemetry is not used for billing, licensing, authentication, or security decisions. Telemetry is enabled by default. To stop both collection and network delivery, set `DO_NOT_TRACK=1` or `DO_NOT_TRACK=true` on the API server and recreate/restart the backend. The deployment identity remains in PostgreSQL, so enabling it again does not create a new identity. `ANALYTICS_DISABLED` controls the separate PostHog integration and does not disable this first-party telemetry. diff --git a/apps/docs/content/docs/environment-variables.fr.mdx b/apps/docs/content/docs/environment-variables.fr.mdx index 932753ebb..e4da9fbd4 100644 --- a/apps/docs/content/docs/environment-variables.fr.mdx +++ b/apps/docs/content/docs/environment-variables.fr.mdx @@ -314,7 +314,7 @@ Quelques règles concernant les valeurs : | `METRICS_ADDR` | vide | Adresse d'écoute des métriques Prometheus ; vide signifie non démarré | | `REALTIME_METRICS_TOKEN` | vide | Jeton Bearer qui protège `/health/realtime` | -La télémétrie propre à Multica pour les instances auto-hébergées envoie un instantané par jour UTC, au niveau du déploiement, au point de terminaison fixe et non configurable `https://telemetry.multica.ai/v1/telemetry/events`. Il contient la version officielle publiée ; les nombres, regroupés par tranches, d'espaces de travail, de membres humains distincts, d'agents actifs et de daemons actifs sur 24 heures ; ainsi que le nombre agrégé d'exécutions des 24 heures précédentes. Il n'inclut jamais de noms, d'adresses ou de domaines e-mail, d'adresses IP, d'identifiants métier, de détails sur les hôtes ou les appareils, de dépôts, de modèles ou de plugins, de prompts ou de sorties, de commentaires ou de discussions, de chemins, de données de tokens ou de coûts, d'identifiants de connexion, d'erreurs, de piles d'appels ni de journaux. Il sert uniquement à comprendre l'adoption des versions auto-hébergées, les tranches de taille des déploiements et l'usage agrégé — et non à la facturation, aux licences, à l'authentification ou aux décisions de sécurité. +La télémétrie propre à Multica pour les instances auto-hébergées envoie un instantané par jour UTC, au niveau du déploiement, au point de terminaison fixe et non configurable `https://telemetry.multica.ai/v1/telemetry/events`. Il contient la version officielle publiée ; les nombres, regroupés par tranches, d'espaces de travail, de membres humains distincts, d'agents actifs et de daemons actifs sur 24 heures ; le nombre agrégé d'exécutions des 24 heures précédentes ; ainsi que les nombres, regroupés par tranches, de membres ayant ouvert l'application web ou de bureau connectée et de membres ayant lancé eux-mêmes une exécution d'agent (attribution, mention, commentaire, chat, exécution manuelle ou relance — hors autopilot et délégation entre agents) au cours des 1, 7 et 28 jours UTC complets précédents. Les membres sont comptés au sein du déploiement et ne sont jamais identifiés. Il n'inclut jamais de noms, d'adresses ou de domaines e-mail, d'adresses IP, d'identifiants métier, de détails sur les hôtes ou les appareils, de dépôts, de modèles ou de plugins, de prompts ou de sorties, de commentaires ou de discussions, de chemins, de données de tokens ou de coûts, d'identifiants de connexion, d'erreurs, de piles d'appels ni de journaux. Il sert uniquement à comprendre l'adoption des versions auto-hébergées, les tranches de taille des déploiements, l'usage agrégé et l'activité des membres — et non à la facturation, aux licences, à l'authentification ou aux décisions de sécurité. `DO_NOT_TRACK` et `ANALYTICS_DISABLED` sont indépendantes. La première contrôle cet instantané anonyme propre à Multica ; la seconde contrôle l'intégration facultative avec PostHog. diff --git a/apps/docs/content/docs/environment-variables.ja.mdx b/apps/docs/content/docs/environment-variables.ja.mdx index dd214b58a..e991579dc 100644 --- a/apps/docs/content/docs/environment-variables.ja.mdx +++ b/apps/docs/content/docs/environment-variables.ja.mdx @@ -317,7 +317,7 @@ multica config show | `METRICS_ADDR` | 空 | Prometheus metrics のリッスンアドレス。空なら起動しません | | `REALTIME_METRICS_TOKEN` | 空 | `/health/realtime` を保護する bearer token | -ファーストパーティのセルフホストテレメトリーは、UTC 日ごとにデプロイ単位のスナップショットを、固定かつ設定変更不可の `https://telemetry.multica.ai/v1/telemetry/events` へ送信します。正式リリース版、ワークスペース数・重複を除いたメンバー数・アクティブなエージェント数・過去 24 時間のアクティブなデーモン数のバケット、および過去 24 時間に開始・完了・失敗・キャンセルされた実行の集計値だけを含みます。名前、メールアドレスやドメイン、IP、業務 ID、ホストやデバイス情報、リポジトリ、モデルや plugin、prompt/output、コメントやチャット、ファイルパス、token/費用、認証情報、エラー、stack、ログは送信しません。このデータはセルフホスト版の採用状況、デプロイ規模、集計利用量の把握だけに使われ、課金、ライセンス、認証、セキュリティ判断には使われません。 +ファーストパーティのセルフホストテレメトリーは、UTC 日ごとにデプロイ単位のスナップショットを、固定かつ設定変更不可の `https://telemetry.multica.ai/v1/telemetry/events` へ送信します。正式リリース版、ワークスペース数・重複を除いたメンバー数・アクティブなエージェント数・過去 24 時間のアクティブなデーモン数のバケット、および過去 24 時間に開始・完了・失敗・キャンセルされた実行の集計値、そして過去 1・7・28 完全 UTC 日にサインインして Web 版またはデスクトップ版を開いたメンバー数と、自分でエージェントの実行を開始した(割り当て、メンション、コメント、チャット、手動実行、再実行。autopilot やエージェント間の委任は含まない)メンバー数のバケットだけを含みます。メンバーはデプロイ内でのみ数えられ、識別されることはありません。名前、メールアドレスやドメイン、IP、業務 ID、ホストやデバイス情報、リポジトリ、モデルや plugin、prompt/output、コメントやチャット、ファイルパス、token/費用、認証情報、エラー、stack、ログは送信しません。このデータはセルフホスト版の採用状況、デプロイ規模、集計利用量、メンバーの利用状況の把握だけに使われ、課金、ライセンス、認証、セキュリティ判断には使われません。 `DO_NOT_TRACK` と `ANALYTICS_DISABLED` は独立しています。前者はこの匿名スナップショットを、後者は任意の PostHog 連携を制御します。 diff --git a/apps/docs/content/docs/environment-variables.ko.mdx b/apps/docs/content/docs/environment-variables.ko.mdx index 5c7cc7f71..17384d029 100644 --- a/apps/docs/content/docs/environment-variables.ko.mdx +++ b/apps/docs/content/docs/environment-variables.ko.mdx @@ -317,7 +317,7 @@ multica config show | `METRICS_ADDR` | 비어 있음 | Prometheus metrics listen 주소. 비어 있으면 시작하지 않음 | | `REALTIME_METRICS_TOKEN` | 비어 있음 | `/health/realtime`을 보호하는 bearer token | -자사 셀프 호스트 텔레메트리는 UTC 기준 하루에 한 번 배포 단위 스냅샷을 고정되어 설정할 수 없는 `https://telemetry.multica.ai/v1/telemetry/events` 주소로 전송합니다. 정식 릴리스 버전, 작업 공간 수·중복을 제거한 멤버 수·활성 에이전트 수·지난 24시간 활성 데몬 수의 버킷, 그리고 지난 24시간 시작·완료·실패·취소된 실행의 집계만 포함합니다. 이름, 이메일 주소나 도메인, IP, 업무 ID, 호스트나 기기 정보, 저장소, 모델이나 plugin, prompt/output, 댓글이나 채팅, 파일 경로, token/비용, 자격 증명, 오류, stack, 로그는 전송하지 않습니다. 이 데이터는 셀프 호스트 버전 사용 현황, 배포 규모, 집계 사용량을 파악하는 데만 쓰며 결제, 라이선스, 인증, 보안 판단에는 사용하지 않습니다. +자사 셀프 호스트 텔레메트리는 UTC 기준 하루에 한 번 배포 단위 스냅샷을 고정되어 설정할 수 없는 `https://telemetry.multica.ai/v1/telemetry/events` 주소로 전송합니다. 정식 릴리스 버전, 작업 공간 수·중복을 제거한 멤버 수·활성 에이전트 수·지난 24시간 활성 데몬 수의 버킷, 그리고 지난 24시간 시작·완료·실패·취소된 실행의 집계, 그리고 지난 1·7·28 완전한 UTC 일 동안 로그인해 웹 또는 데스크톱 앱을 연 멤버 수와 직접 에이전트 실행을 시작한(할당, 멘션, 댓글, 채팅, 수동 실행, 재실행. autopilot과 에이전트 간 위임은 제외) 멤버 수의 버킷만 포함합니다. 멤버는 배포 안에서만 집계되며 식별되지 않습니다. 이름, 이메일 주소나 도메인, IP, 업무 ID, 호스트나 기기 정보, 저장소, 모델이나 plugin, prompt/output, 댓글이나 채팅, 파일 경로, token/비용, 자격 증명, 오류, stack, 로그는 전송하지 않습니다. 이 데이터는 셀프 호스트 버전 사용 현황, 배포 규모, 집계 사용량, 멤버 활동을 파악하는 데만 쓰며 결제, 라이선스, 인증, 보안 판단에는 사용하지 않습니다. `DO_NOT_TRACK`과 `ANALYTICS_DISABLED`는 서로 독립적입니다. 전자는 이 익명 스냅샷을, 후자는 선택 사항인 PostHog 연동을 제어합니다. diff --git a/apps/docs/content/docs/environment-variables.mdx b/apps/docs/content/docs/environment-variables.mdx index 9e2182861..694af50aa 100644 --- a/apps/docs/content/docs/environment-variables.mdx +++ b/apps/docs/content/docs/environment-variables.mdx @@ -317,7 +317,7 @@ A few value rules: | `METRICS_ADDR` | empty | Prometheus metrics listen address; empty means not started | | `REALTIME_METRICS_TOKEN` | empty | Bearer token protecting `/health/realtime` | -First-party self-host telemetry sends one deployment-level snapshot per UTC day to the fixed, non-configurable `https://telemetry.multica.ai/v1/telemetry/events` endpoint. It contains the formal release version; bucketed workspace, distinct-human-member, active-agent, and 24-hour active-daemon counts; and aggregate run counts for the previous 24 hours. It never includes names, email addresses or domains, IP addresses, business identifiers, host/device details, repositories, models/plugins, prompts/output, comments/chats, paths, token/cost data, credentials, errors, stacks, or logs. It is used only to understand self-hosted version adoption, deployment-size ranges, and aggregate usage—not for billing, licensing, authentication, or security decisions. +First-party self-host telemetry sends one deployment-level snapshot per UTC day to the fixed, non-configurable `https://telemetry.multica.ai/v1/telemetry/events` endpoint. It contains the formal release version; bucketed workspace, distinct-human-member, active-agent, and 24-hour active-daemon counts; aggregate run counts for the previous 24 hours; and bucketed counts of members who opened the signed-in web or desktop app, and of members who personally started an agent run (assign, mention, comment, chat, manual run, or rerun — not autopilot or agent-to-agent delegation), in the previous 1, 7, and 28 complete UTC days. Members are counted per deployment and are never identified. It never includes names, email addresses or domains, IP addresses, business identifiers, host/device details, repositories, models/plugins, prompts/output, comments/chats, paths, token/cost data, credentials, errors, stacks, or logs. It is used only to understand self-hosted version adoption, deployment-size ranges, aggregate usage, and member activity—not for billing, licensing, authentication, or security decisions. `DO_NOT_TRACK` and `ANALYTICS_DISABLED` are independent. The former controls this first-party anonymous snapshot; the latter controls the optional PostHog integration. diff --git a/apps/docs/content/docs/environment-variables.zh.mdx b/apps/docs/content/docs/environment-variables.zh.mdx index d8428acb1..28e8f2ade 100644 --- a/apps/docs/content/docs/environment-variables.zh.mdx +++ b/apps/docs/content/docs/environment-variables.zh.mdx @@ -317,7 +317,7 @@ multica config show | `METRICS_ADDR` | 空 | Prometheus metrics 监听地址;空表示不启动 | | `REALTIME_METRICS_TOKEN` | 空 | 保护 `/health/realtime` 的 bearer token | -首方自托管遥测每天按 UTC 发送一份部署级快照,固定且不可配置的地址为 `https://telemetry.multica.ai/v1/telemetry/events`。快照只包含正式发布版本、工作区数/去重成员数/活跃智能体数/过去 24 小时活跃守护进程数的分桶,以及过去 24 小时运行的开始、完成、失败和取消总数。不会发送名称、邮箱或域名、IP、业务标识符、主机或设备信息、代码仓库、模型或 plugin、prompt/output、评论或聊天、文件路径、token/费用、凭据、错误文本、stack 或日志。此数据只用于了解自托管版本采用、部署规模区间与聚合使用量,不用于计费、授权、认证或安全决策。 +首方自托管遥测每天按 UTC 发送一份部署级快照,固定且不可配置的地址为 `https://telemetry.multica.ai/v1/telemetry/events`。快照只包含正式发布版本、工作区数/去重成员数/活跃智能体数/过去 24 小时活跃守护进程数的分桶,过去 24 小时运行的开始、完成、失败和取消总数,以及过去 1、7、28 个完整 UTC 日内登录后打开过网页端或桌面端的成员数、亲自发起过智能体运行(分配、@、评论、聊天、手动运行或重跑;不含 autopilot 和智能体之间的委派)的成员数的分桶。成员只在本部署内计数,不会被识别。不会发送名称、邮箱或域名、IP、业务标识符、主机或设备信息、代码仓库、模型或 plugin、prompt/output、评论或聊天、文件路径、token/费用、凭据、错误文本、stack 或日志。此数据只用于了解自托管版本采用、部署规模区间、聚合使用量与成员活跃情况,不用于计费、授权、认证或安全决策。 `DO_NOT_TRACK` 与 `ANALYTICS_DISABLED` 相互独立:前者控制这份首方匿名快照,后者控制可选的 PostHog 集成。 diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts index 2cbd49225..4eacec577 100644 --- a/apps/web/features/landing/i18n/en.ts +++ b/apps/web/features/landing/i18n/en.ts @@ -402,10 +402,10 @@ export function createEnDict( privacy: { title: "Privacy Policy", - lastUpdated: "Last updated: September 24, 2026", + lastUpdated: "Last updated: October 22, 2026", intro: [ "This Privacy Policy explains how Index Labs (Hong Kong) Limited (“Multica”, “we”, “us”) collects, uses, and shares personal information when you visit multica.ai, contact us, or use Multica Cloud, our hosted service, including the web, desktop, and mobile apps.", - "It does not cover Multica deployments you host yourself. The operator of a self-hosted deployment controls its data, and any AI providers, integrations, or analytics it uses depend on how they configure it. The only thing a self-hosted server sends us is a daily usage snapshot: a random ID for the deployment, so snapshots from the same server can be linked; the server version; approximate counts of workspaces, members, agents, and connected daemons; and the number of agent runs started, completed, failed, and cancelled that day. It contains no names, email addresses, or content. Setting DO_NOT_TRACK=1 turns off this snapshot.", + "It does not cover Multica deployments you host yourself. The operator of a self-hosted deployment controls its data, and any AI providers, integrations, or analytics it uses depend on how they configure it. The only thing a self-hosted server sends us is a daily usage snapshot: a random ID for the deployment, so snapshots from the same server can be linked; the server version; approximate counts of workspaces, members, agents, and connected daemons; the number of agent runs started, completed, failed, and cancelled that day; and approximate counts of members who opened the web or desktop app, and of members who personally started an agent run, over the previous 1, 7, and 28 days. It contains no names, email addresses, or content. Setting DO_NOT_TRACK=1 turns off this snapshot.", ], sections: [ { @@ -868,7 +868,7 @@ export function createEnDict( "Drag a status, built-in ones included, to set its place in boards, lists, and status menus.", "Give a custom status the icon shape you want.", "DeepSeek Harness Desktop is picked up as a runtime on its own, with no manual setup.", - "Self-hosted servers send one anonymous daily snapshot of deployment size and run volume; set DO_NOT_TRACK=1 to turn it off.", + "Self-hosted servers send one anonymous daily snapshot of deployment size, member activity, and run volume; set DO_NOT_TRACK=1 to turn it off.", ], improvements: [ "Archiving a status tells you how many Issues still use it, and takes you to them.", diff --git a/apps/web/features/landing/i18n/ja.ts b/apps/web/features/landing/i18n/ja.ts index 309354d99..8a5470757 100644 --- a/apps/web/features/landing/i18n/ja.ts +++ b/apps/web/features/landing/i18n/ja.ts @@ -3397,10 +3397,10 @@ export function createJaDict( }, privacy: { title: "プライバシーポリシー", - lastUpdated: "最終更新日: 2026年9月24日", + lastUpdated: "最終更新日: 2026年10月22日", intro: [ "このプライバシーポリシーは、Index Labs (Hong Kong) Limited(以下「Multica」または「当社」)が、multica.ai へのアクセス、当社へのお問い合わせ、またはホスティングサービスである Multica Cloud(Web、デスクトップ、モバイルアプリを含む)のご利用に際して、個人情報をどのように収集、利用、共有するかを説明するものです。", - "このポリシーは、お客様がご自身でホストする Multica には適用されません。セルフホスト環境のデータは運用者が管理し、どの AI プロバイダー、連携サービス、分析ツールを使うかは運用者の設定によります。セルフホストのサーバーが当社に送信するのは、1日1回の利用状況スナップショットのみです。内容は、同じサーバーのスナップショットを関連付けるためのランダムな環境 ID、サーバーのバージョン、ワークスペース・メンバー・エージェント・接続中のデーモンのおおよその数、その日に開始・完了・失敗・キャンセルされた実行の数です。氏名、メールアドレス、コンテンツは含まれません。DO_NOT_TRACK=1 を設定すると、このスナップショットの送信を停止できます。", + "このポリシーは、お客様がご自身でホストする Multica には適用されません。セルフホスト環境のデータは運用者が管理し、どの AI プロバイダー、連携サービス、分析ツールを使うかは運用者の設定によります。セルフホストのサーバーが当社に送信するのは、1日1回の利用状況スナップショットのみです。内容は、同じサーバーのスナップショットを関連付けるためのランダムな環境 ID、サーバーのバージョン、ワークスペース・メンバー・エージェント・接続中のデーモンのおおよその数、その日に開始・完了・失敗・キャンセルされた実行の数、そして過去 1・7・28 日間に Web 版またはデスクトップ版を開いたメンバー数と、自分でエージェントの実行を開始したメンバー数のおおよその値です。氏名、メールアドレス、コンテンツは含まれません。DO_NOT_TRACK=1 を設定すると、このスナップショットの送信を停止できます。", "このポリシーは英語版を正本とします。日本語版と英語版の内容が異なる場合は、英語版が優先されます。", ], sections: [ diff --git a/apps/web/features/landing/i18n/ko.ts b/apps/web/features/landing/i18n/ko.ts index e273be1b9..014a4f763 100644 --- a/apps/web/features/landing/i18n/ko.ts +++ b/apps/web/features/landing/i18n/ko.ts @@ -3418,10 +3418,10 @@ export function createKoDict( }, privacy: { title: "개인정보 처리방침", - lastUpdated: "최종 업데이트: 2026년 9월 24일", + lastUpdated: "최종 업데이트: 2026년 10월 22일", intro: [ "이 개인정보 처리방침은 Index Labs (Hong Kong) Limited(이하 “Multica” 또는 “저희”)가 여러분이 multica.ai를 방문하거나, 저희에게 문의하거나, 호스팅 서비스인 Multica Cloud(웹, 데스크톱, 모바일 앱 포함)를 이용할 때 개인정보를 어떻게 수집, 이용, 공유하는지 설명합니다.", - "이 방침은 여러분이 직접 호스팅하는 Multica에는 적용되지 않습니다. 셀프 호스팅 배포의 데이터는 운영자가 관리하며, 어떤 AI 제공자, 연동 서비스, 분석 도구를 쓰는지는 운영자의 설정에 따라 달라집니다. 셀프 호스팅 서버가 저희에게 보내는 것은 하루 한 번의 사용 현황 스냅샷뿐입니다. 여기에는 같은 서버의 스냅샷을 서로 연결하기 위한 무작위 배포 ID, 서버 버전, 워크스페이스·멤버·에이전트·연결된 데몬의 대략적인 수, 그날 시작·완료·실패·취소된 실행 수가 담깁니다. 이름, 이메일 주소, 콘텐츠는 포함되지 않습니다. DO_NOT_TRACK=1로 설정하면 이 스냅샷을 끌 수 있습니다.", + "이 방침은 여러분이 직접 호스팅하는 Multica에는 적용되지 않습니다. 셀프 호스팅 배포의 데이터는 운영자가 관리하며, 어떤 AI 제공자, 연동 서비스, 분석 도구를 쓰는지는 운영자의 설정에 따라 달라집니다. 셀프 호스팅 서버가 저희에게 보내는 것은 하루 한 번의 사용 현황 스냅샷뿐입니다. 여기에는 같은 서버의 스냅샷을 서로 연결하기 위한 무작위 배포 ID, 서버 버전, 워크스페이스·멤버·에이전트·연결된 데몬의 대략적인 수, 그날 시작·완료·실패·취소된 실행 수, 그리고 지난 1·7·28일 동안 웹 또는 데스크톱 앱을 연 멤버 수와 직접 에이전트 실행을 시작한 멤버 수의 대략적인 값이 담깁니다. 이름, 이메일 주소, 콘텐츠는 포함되지 않습니다. DO_NOT_TRACK=1로 설정하면 이 스냅샷을 끌 수 있습니다.", "이 방침은 영어 원문을 기준으로 합니다. 한국어 번역본과 영어 원문의 내용이 다를 경우 영어 원문이 우선합니다.", ], sections: [ diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts index 7edff7b4e..73c261484 100644 --- a/apps/web/features/landing/i18n/zh.ts +++ b/apps/web/features/landing/i18n/zh.ts @@ -386,10 +386,10 @@ export function createZhDict( privacy: { title: "隐私政策", - lastUpdated: "最后更新:2026 年 9 月 24 日", + lastUpdated: "最后更新:2026 年 10 月 22 日", intro: [ "本隐私政策说明 Index Labs (Hong Kong) Limited(下称「Multica」或「我们」)在你访问 multica.ai、联系我们或使用我们的托管服务 Multica Cloud(包括网页端、桌面端和移动端)时,如何收集、使用和共享个人信息。", - "本政策不适用于你自行部署的 Multica。自托管部署的数据由部署方控制,它使用哪些 AI 服务商、集成或分析工具,取决于部署方的配置。自托管服务器唯一会发送给我们的是每天一次的使用快照,内容包括:一个随机生成的部署 ID(用于关联同一台服务器的历次快照)、服务器版本、工作区、成员、智能体和已连接守护进程的大致数量,以及当天开始、完成、失败和取消的运行次数。快照不含任何姓名、邮箱或内容。设置 DO_NOT_TRACK=1 即可关闭这份快照。", + "本政策不适用于你自行部署的 Multica。自托管部署的数据由部署方控制,它使用哪些 AI 服务商、集成或分析工具,取决于部署方的配置。自托管服务器唯一会发送给我们的是每天一次的使用快照,内容包括:一个随机生成的部署 ID(用于关联同一台服务器的历次快照)、服务器版本、工作区、成员、智能体和已连接守护进程的大致数量,当天开始、完成、失败和取消的运行次数,以及过去 1、7、28 天内打开过网页端或桌面端的成员、亲自发起过智能体运行的成员的大致人数。快照不含任何姓名、邮箱或内容。设置 DO_NOT_TRACK=1 即可关闭这份快照。", "本政策以英文版本为准。如中文版本与英文版本不一致,以英文版本为准。", ], sections: [ diff --git a/server/cmd/migrate/main.go b/server/cmd/migrate/main.go index 48d20158a..2149d6c5f 100644 --- a/server/cmd/migrate/main.go +++ b/server/cmd/migrate/main.go @@ -188,6 +188,7 @@ var concurrentIndexCleanups = map[string]string{ "487_maintenance_job_idempotency_index": "idx_maintenance_job_idempotency", "488_maintenance_job_active_index": "idx_maintenance_job_active", "587_cloud_agent_connection_provider_index": "cloud_agent_connection_provider_index", + "590_member_delegation_day_unique_index": "member_delegation_day_user_date_uidx", "035_task_queue_issue_id_index": "idx_agent_task_queue_issue_id", "067_task_queue_claim_candidate_index": "idx_agent_task_queue_claim_candidates", "074_task_usage_updated_at_index": "idx_task_usage_updated_at", diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 677246b15..c07e63edb 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -694,6 +694,9 @@ func main() { LLMMaxRetries: llmMaxRetries, LLMDisableThinking: llmDisableThinking, }) + // member_delegation_day exists only for self-host telemetry; DO_NOT_TRACK + // stops writing it as well as sending the snapshot. + h.TaskService.RecordMemberActivity = telemetryConfig.Enabled var replicaQueries *db.Queries if replicaPool != nil { replicaQueries = db.New(replicaPool) diff --git a/server/internal/attribution/attribution.go b/server/internal/attribution/attribution.go index c1e834212..3a9e7d2c6 100644 --- a/server/internal/attribution/attribution.go +++ b/server/internal/attribution/attribution.go @@ -174,6 +174,18 @@ type Result struct { RerunOfTaskID pgtype.UUID EvidenceKind EvidenceKind EvidenceRefID pgtype.UUID + + // HumanAction is true only when UserID's own deliberate action caused this + // enqueue: a member comment, an assign/promote/manual trigger/rerun by an + // actor, a chat message, a quick-create. It is false for runs derived on a + // human's behalf (issue-creator fallback for system wakeups, delegation, + // trigger_owner), even though some of those also carry SourceDirectHuman. + // Activity reporting only — never consulted for authorization. + HumanAction bool + // HumanActedAt is when that action happened, when it is known to differ from + // the enqueue time (a member comment folded or re-enqueued later). Invalid + // means "now". + HumanActedAt pgtype.Timestamptz } // finalizeAttribution enforces the one-way Phase 1 accountability invariant @@ -198,6 +210,7 @@ type CommentFacts struct { CommentID pgtype.UUID AuthorType string // "member" | "agent" | other AuthorID pgtype.UUID + CreatedAt pgtype.Timestamptz // For agent-authored comments: the source task the comment was written // from (comment.source_task_id) and that task's originator_user_id. The @@ -229,6 +242,8 @@ func ClassifyComment(f CommentFacts, agentAuthoredSource Source) Result { Source: SourceDirectHuman, EvidenceKind: EvidenceComment, EvidenceRefID: f.CommentID, + HumanAction: f.AuthorID.Valid, + HumanActedAt: f.CreatedAt, }) case "agent": r := Result{EvidenceKind: EvidenceComment, EvidenceRefID: f.CommentID} @@ -304,6 +319,7 @@ func ClassifyDirect(f DirectFacts) Result { Source: SourceDirectHuman, EvidenceKind: EvidenceIssueAssignment, EvidenceRefID: f.IssueID, + HumanAction: true, }) } if f.CreatorType == "member" && f.CreatorID.Valid { @@ -354,6 +370,7 @@ func DirectHumanRun(userID pgtype.UUID, evidenceKind EvidenceKind, evidenceRefID Source: SourceDirectHuman, EvidenceKind: evidenceKind, EvidenceRefID: evidenceRefID, + HumanAction: true, }) } diff --git a/server/internal/attribution/attribution_test.go b/server/internal/attribution/attribution_test.go index d56c0fb76..8aab997d1 100644 --- a/server/internal/attribution/attribution_test.go +++ b/server/internal/attribution/attribution_test.go @@ -2,6 +2,7 @@ package attribution import ( "testing" + "time" "github.com/jackc/pgx/v5/pgtype" ) @@ -504,3 +505,32 @@ func TestSourceStringDefaultsToUnattributed(t *testing.T) { t.Errorf("unexpected string for direct_human: %q", SourceDirectHuman.String()) } } + +// HumanAction feeds the self-host "delegating member" count: only the human +// who acted counts, never a human the run merely inherits. +func TestHumanActionOnlyForTheActingMember(t *testing.T) { + member := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + acted := pgtype.Timestamptz{Time: time.Date(2026, 9, 9, 23, 0, 0, 0, time.UTC), Valid: true} + cases := []struct { + name string + got Result + want bool + }{ + {"member comment", ClassifyComment(CommentFacts{AuthorType: "member", AuthorID: member, CreatedAt: acted}, SourceDelegation), true}, + {"agent comment", ClassifyComment(CommentFacts{AuthorType: "agent", SourceTaskID: member, ParentOriginator: member}, SourceDelegation), false}, + {"actor assignment", ClassifyDirect(DirectFacts{ActorUserID: member}), true}, + {"issue creator fallback", ClassifyDirect(DirectFacts{CreatorType: "member", CreatorID: member}), false}, + {"agent-created origin", ClassifyDirect(DirectFacts{OriginType: "agent_create", OriginOriginator: member}), false}, + {"chat sender", DirectHumanRun(member, EvidenceChat, pgtype.UUID{}), true}, + {"unresolved chat sender", DirectHumanRun(pgtype.UUID{}, EvidenceChat, pgtype.UUID{}), false}, + {"trigger owner", TriggerOwner(member, EvidenceAutopilotRun, pgtype.UUID{}), false}, + } + for _, tc := range cases { + if tc.got.HumanAction != tc.want { + t.Errorf("%s: HumanAction = %v, want %v", tc.name, tc.got.HumanAction, tc.want) + } + } + if got := cases[0].got.HumanActedAt; got != acted { + t.Errorf("member comment HumanActedAt = %v, want the comment's created_at", got) + } +} diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 074af0899..61f880648 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -2447,6 +2447,9 @@ func (h *Handler) mergeCommentIntoPendingTask(ctx context.Context, issue db.Issu "agent_id", uuidToString(trigger.Agent.ID), "new_trigger_comment_id", uuidToString(newTriggerCommentID), "coalesced_count", len(row.CoalescedCommentIds)) + // The fold re-attributes the queued run to this comment's author; record that + // human's action now, since the run row keeps no history of who acted. + h.TaskService.RecordHumanDelegation(ctx, attr) return commentMergeSucceeded } diff --git a/server/internal/handler/workspace_delete_manifest_test.go b/server/internal/handler/workspace_delete_manifest_test.go index 53f8c28db..d08fe18ba 100644 --- a/server/internal/handler/workspace_delete_manifest_test.go +++ b/server/internal/handler/workspace_delete_manifest_test.go @@ -101,6 +101,7 @@ var workspaceDeletionManifest = map[string]workspaceDeleteAction{ "lark_user_binding": workspaceDelete, "maintenance_job": workspaceDeleteKeep, // Global maintenance audit history, not workspace-owned. "member": workspaceDelete, + "member_delegation_day": workspaceDeleteKeep, // user-scoped telemetry ledger, no workspace_id "agent_mcp_server": workspaceDelete, "workspace_mcp_server": workspaceDelete, "notification_preference": workspaceDelete, diff --git a/server/internal/selfhosttelemetry/collector.go b/server/internal/selfhosttelemetry/collector.go index f249b5e75..a3f8f5941 100644 --- a/server/internal/selfhosttelemetry/collector.go +++ b/server/internal/selfhosttelemetry/collector.go @@ -36,6 +36,45 @@ SELECT terminal_tasks.cancelled FROM terminal_tasks` +// activitySQL counts distinct current members active in the N complete UTC +// days before the snapshot's UTC day ($1). Windows are calendar days, not +// [T-24h, T), because both sources record only an activity date. +// +// - visiting: the signed-in web or desktop app reported the member that day +// (client_usage_daily). +// - delegating: the member personally started an agent run that day +// (member_delegation_day, written at the human action itself, so retries, +// system wakeups, delegation and autopilot never appear, and a comment +// folded into another member's queued run keeps both members and days). +// +// Both are bounded 28-day range reads on (activity_date, ...) / (user_id, +// activity_date) indexes, independent of task history size. +const activitySQL = ` +WITH members AS ( + SELECT DISTINCT user_id FROM member +), visits AS ( + SELECT c.user_id, max(c.activity_date) AS last_day + FROM client_usage_daily c + JOIN members m ON m.user_id = c.user_id + WHERE c.activity_date >= $1::date - 28 + AND c.activity_date < $1::date + GROUP BY c.user_id +), delegations AS ( + SELECT d.user_id, max(d.activity_date) AS last_day + FROM member_delegation_day d + JOIN members m ON m.user_id = d.user_id + WHERE d.activity_date >= $1::date - 28 + AND d.activity_date < $1::date + GROUP BY d.user_id +) +SELECT + (SELECT count(*) FROM visits WHERE last_day >= $1::date - 1), + (SELECT count(*) FROM visits WHERE last_day >= $1::date - 7), + (SELECT count(*) FROM visits), + (SELECT count(*) FROM delegations WHERE last_day >= $1::date - 1), + (SELECT count(*) FROM delegations WHERE last_day >= $1::date - 7), + (SELECT count(*) FROM delegations)` + const collectionStatementTimeout = 2 * time.Second type eventCollector interface { @@ -80,6 +119,20 @@ func (SQLCollector) Collect(ctx context.Context, conn *pgx.Conn, at time.Time) ( if err != nil { return Counts{}, fmt.Errorf("collect telemetry snapshot: %w", err) } + // Same transaction snapshot and statement timeout as the counts above. A + // failure fails the whole collection, so the worker's normal collection + // retry applies; there is no partial snapshot. + err = tx.QueryRow(ctx, activitySQL, utcDay(at)).Scan( + &counts.Activity.Visiting1d, + &counts.Activity.Visiting7d, + &counts.Activity.Visiting28d, + &counts.Activity.Delegating1d, + &counts.Activity.Delegating7d, + &counts.Activity.Delegating28d, + ) + if err != nil { + return Counts{}, fmt.Errorf("collect telemetry member activity: %w", err) + } if err = tx.Commit(ctx); err != nil { return Counts{}, fmt.Errorf("commit telemetry collection: %w", err) } diff --git a/server/internal/selfhosttelemetry/event.go b/server/internal/selfhosttelemetry/event.go index 4d9fad109..17ae5e91e 100644 --- a/server/internal/selfhosttelemetry/event.go +++ b/server/internal/selfhosttelemetry/event.go @@ -11,8 +11,10 @@ import ( ) const ( - eventName = "instance_daily_snapshot" - schemaVersion = 1 + eventName = "instance_daily_snapshot" + // V2 = the V1 fields plus member-activity buckets. This sender only emits + // V2; the Cloud receiver keeps accepting V1 from older servers. + schemaVersion = 2 maxRequestSize = 16 * 1024 maxTaskCount = int64(1_000_000) ) @@ -41,6 +43,13 @@ type Payload struct { TasksCompleted24h int64 `json:"tasks_completed_24h"` TasksFailed24h int64 `json:"tasks_failed_24h"` TasksCancelled24h int64 `json:"tasks_cancelled_24h"` + + VisitingMemberCount1dBucket string `json:"visiting_member_count_1d_bucket"` + VisitingMemberCount7dBucket string `json:"visiting_member_count_7d_bucket"` + VisitingMemberCount28dBucket string `json:"visiting_member_count_28d_bucket"` + DelegatingMemberCount1dBucket string `json:"delegating_member_count_1d_bucket"` + DelegatingMemberCount7dBucket string `json:"delegating_member_count_7d_bucket"` + DelegatingMemberCount28dBucket string `json:"delegating_member_count_28d_bucket"` } // Counts contains deployment-wide aggregates at one snapshot instant. @@ -53,9 +62,21 @@ type Counts struct { TasksCompleted int64 TasksFailed int64 TasksCancelled int64 + + Activity ActivityCounts +} + +// ActivityCounts are distinct current members active in the 1, 7 and 28 +// complete UTC days before the snapshot day. +type ActivityCounts struct { + // Visiting: the signed-in web or desktop app was opened. + Visiting1d, Visiting7d, Visiting28d int64 + // Delegating: the member personally started an agent run. + Delegating1d, Delegating7d, Delegating28d int64 } func newEvent(instanceID uuid.UUID, occurredAt time.Time, serverVersion string, counts Counts) Event { + a := counts.Activity return Event{ EventName: eventName, SchemaVersion: schemaVersion, @@ -71,6 +92,13 @@ func newEvent(instanceID uuid.UUID, occurredAt time.Time, serverVersion string, TasksCompleted24h: clampTaskCount(counts.TasksCompleted), TasksFailed24h: clampTaskCount(counts.TasksFailed), TasksCancelled24h: clampTaskCount(counts.TasksCancelled), + + VisitingMemberCount1dBucket: countBucket(a.Visiting1d), + VisitingMemberCount7dBucket: countBucket(a.Visiting7d), + VisitingMemberCount28dBucket: countBucket(a.Visiting28d), + DelegatingMemberCount1dBucket: countBucket(a.Delegating1d), + DelegatingMemberCount7dBucket: countBucket(a.Delegating7d), + DelegatingMemberCount28dBucket: countBucket(a.Delegating28d), }, } } diff --git a/server/internal/selfhosttelemetry/event_test.go b/server/internal/selfhosttelemetry/event_test.go index e89b36d4a..6500ed7f4 100644 --- a/server/internal/selfhosttelemetry/event_test.go +++ b/server/internal/selfhosttelemetry/event_test.go @@ -102,7 +102,7 @@ func TestEventGoldenJSON(t *testing.T) { if err != nil { t.Fatal(err) } - want := `{"event_name":"instance_daily_snapshot","schema_version":1,"instance_id":"11111111-2222-3333-4444-555555555555","occurred_at":"2026-09-09T16:00:00Z","payload":{"server_version":"v1.0.0","workspace_count_bucket":"2-5","human_member_count_bucket":"6-20","agent_count_bucket":"6-20","active_daemon_count_24h_bucket":"1","tasks_started_24h":12,"tasks_completed_24h":10,"tasks_failed_24h":1,"tasks_cancelled_24h":1}}` + want := `{"event_name":"instance_daily_snapshot","schema_version":2,"instance_id":"11111111-2222-3333-4444-555555555555","occurred_at":"2026-09-09T16:00:00Z","payload":{"server_version":"v1.0.0","workspace_count_bucket":"2-5","human_member_count_bucket":"6-20","agent_count_bucket":"6-20","active_daemon_count_24h_bucket":"1","tasks_started_24h":12,"tasks_completed_24h":10,"tasks_failed_24h":1,"tasks_cancelled_24h":1,"visiting_member_count_1d_bucket":"0","visiting_member_count_7d_bucket":"0","visiting_member_count_28d_bucket":"0","delegating_member_count_1d_bucket":"0","delegating_member_count_7d_bucket":"0","delegating_member_count_28d_bucket":"0"}}` if string(body) != want { t.Fatalf("body mismatch\n got: %s\nwant: %s", body, want) } @@ -118,8 +118,33 @@ func TestEventGoldenJSON(t *testing.T) { if err := json.Unmarshal(top["payload"], &payload); err != nil { t.Fatal(err) } - if len(payload) != 9 { - t.Fatalf("payload key count = %d, want exactly 9", len(payload)) + if len(payload) != 15 { + t.Fatalf("payload key count = %d, want exactly 15", len(payload)) + } +} + +func TestEventV2GoldenJSON(t *testing.T) { + t.Parallel() + event := newEvent( + uuid.MustParse("11111111-2222-3333-4444-555555555555"), + time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC), + "v1.0.0", + Counts{ + Workspaces: 2, HumanMembers: 6, Agents: 20, ActiveDaemons: 1, + TasksStarted: 12, TasksCompleted: 10, TasksFailed: 1, TasksCancelled: 1, + Activity: ActivityCounts{ + Visiting1d: 1, Visiting7d: 3, Visiting28d: 6, + Delegating1d: 0, Delegating7d: 2, Delegating28d: 101, + }, + }, + ) + body, err := marshalEvent(event) + if err != nil { + t.Fatal(err) + } + want := `{"event_name":"instance_daily_snapshot","schema_version":2,"instance_id":"11111111-2222-3333-4444-555555555555","occurred_at":"2026-09-10T00:00:00Z","payload":{"server_version":"v1.0.0","workspace_count_bucket":"2-5","human_member_count_bucket":"6-20","agent_count_bucket":"6-20","active_daemon_count_24h_bucket":"1","tasks_started_24h":12,"tasks_completed_24h":10,"tasks_failed_24h":1,"tasks_cancelled_24h":1,"visiting_member_count_1d_bucket":"1","visiting_member_count_7d_bucket":"2-5","visiting_member_count_28d_bucket":"6-20","delegating_member_count_1d_bucket":"0","delegating_member_count_7d_bucket":"2-5","delegating_member_count_28d_bucket":"101+"}}` + if string(body) != want { + t.Fatalf("body mismatch\n got: %s\nwant: %s", body, want) } } diff --git a/server/internal/selfhosttelemetry/postgres_test.go b/server/internal/selfhosttelemetry/postgres_test.go index 22284a4d0..22b59f0f4 100644 --- a/server/internal/selfhosttelemetry/postgres_test.go +++ b/server/internal/selfhosttelemetry/postgres_test.go @@ -303,6 +303,8 @@ func TestSQLCollectorUsesOneBoundaryAndWindowIndexes(t *testing.T) { CREATE TABLE agent (archived_at timestamptz); CREATE TABLE agent_runtime (daemon_id text, last_seen_at timestamptz); CREATE TABLE agent_task_queue (status text, started_at timestamptz, completed_at timestamptz); + CREATE TABLE client_usage_daily (user_id uuid NOT NULL, activity_date date NOT NULL); + CREATE TABLE member_delegation_day (user_id uuid NOT NULL, activity_date date NOT NULL); CREATE INDEX idx_agent_task_queue_telemetry_started ON agent_task_queue (started_at) WHERE started_at IS NOT NULL; CREATE INDEX idx_agent_task_queue_terminal_completed_at_v2 @@ -415,3 +417,104 @@ func TestSQLCollectorUsesOneBoundaryAndWindowIndexes(t *testing.T) { } } } + +func TestSQLCollectorCountsMemberActivityByCompleteUTCDays(t *testing.T) { + pool := telemetryTestPool(t) + ctx := context.Background() + if _, err := pool.Exec(ctx, ` + CREATE TABLE workspace (id uuid PRIMARY KEY); + CREATE TABLE member (user_id uuid NOT NULL); + CREATE TABLE agent (archived_at timestamptz); + CREATE TABLE agent_runtime (daemon_id text, last_seen_at timestamptz); + CREATE TABLE agent_task_queue (status text, started_at timestamptz, completed_at timestamptz); + CREATE TABLE client_usage_daily ( + user_id uuid NOT NULL, client_type text NOT NULL, install_id uuid NOT NULL, + activity_date date NOT NULL); + CREATE INDEX client_usage_daily_activity_client_user_idx + ON client_usage_daily (activity_date, client_type, user_id); + CREATE TABLE member_delegation_day ( + user_id uuid NOT NULL, activity_date date NOT NULL, + PRIMARY KEY (user_id, activity_date))`); err != nil { + t.Fatal(err) + } + // Snapshot day 2026-09-10; the 1d window is all of 09-09, 7d starts 09-03, + // 28d starts 08-13. Anything on 09-10 itself is outside every window. + at := time.Date(2026, 9, 10, 3, 0, 0, 0, time.UTC) + const ( + a = "10000000-0000-0000-0000-00000000000a" // member, active yesterday + b = "10000000-0000-0000-0000-00000000000b" // member, active 5 / 7 days ago + c = "10000000-0000-0000-0000-00000000000c" // member, active 20 / 28 days ago + d = "10000000-0000-0000-0000-00000000000d" // member, only today / too old + x = "10000000-0000-0000-0000-0000000000ff" // former member, not in member + ) + if _, err := pool.Exec(ctx, ` + INSERT INTO member VALUES ($1), ($1), ($2), ($3), ($4)`, a, b, c, d); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO client_usage_daily VALUES + ($1, 'web', gen_random_uuid(), '2026-09-09'), + ($1, 'desktop', gen_random_uuid(), '2026-09-09'), + ($2, 'web', gen_random_uuid(), '2026-09-05'), + ($3, 'web', gen_random_uuid(), '2026-08-21'), + ($4, 'web', gen_random_uuid(), '2026-09-10'), + ($4, 'web', gen_random_uuid(), '2026-08-12'), + ($5, 'web', gen_random_uuid(), '2026-09-09')`, a, b, c, d, x); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO member_delegation_day VALUES + ($1, '2026-09-09'), ($1, '2026-09-01'), + ($2, '2026-09-03'), + ($3, '2026-08-13'), + ($4, '2026-09-10'), ($4, '2026-08-12'), + ($5, '2026-09-09')`, a, b, c, d, x); err != nil { + t.Fatal(err) + } + + conn, err := pool.Acquire(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Release() + counts, err := (SQLCollector{}).Collect(ctx, conn.Conn(), at) + if err != nil { + t.Fatal(err) + } + want := ActivityCounts{ + Visiting1d: 1, Visiting7d: 2, Visiting28d: 3, + Delegating1d: 1, Delegating7d: 2, Delegating28d: 3, + } + if counts.Activity != want { + t.Fatalf("activity = %#v, want %#v", counts.Activity, want) + } +} + +// Member activity is part of the snapshot, not an optional add-on: a failed +// read fails collection so the worker's collection retry applies, instead of +// sending a partial snapshot. +func TestSQLCollectorFailsWhenActivityCannotBeRead(t *testing.T) { + pool := telemetryTestPool(t) + ctx := context.Background() + if _, err := pool.Exec(ctx, ` + CREATE TABLE workspace (id uuid PRIMARY KEY); + CREATE TABLE member (user_id uuid NOT NULL); + CREATE TABLE agent (archived_at timestamptz); + CREATE TABLE agent_runtime (daemon_id text, last_seen_at timestamptz); + CREATE TABLE agent_task_queue (status text, started_at timestamptz, completed_at timestamptz); + CREATE TABLE client_usage_daily (user_id uuid NOT NULL, activity_date date NOT NULL)`); err != nil { + t.Fatal(err) + } + conn, err := pool.Acquire(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Release() + if _, err := (SQLCollector{}).Collect(ctx, conn.Conn(), time.Date(2026, 9, 10, 3, 0, 0, 0, time.UTC)); err == nil { + t.Fatal("collection succeeded without member_delegation_day") + } + var timeout string + if err := conn.QueryRow(ctx, "SHOW statement_timeout").Scan(&timeout); err != nil { + t.Fatalf("connection unusable after failed collection: %v", err) + } +} diff --git a/server/internal/service/autopilot.go b/server/internal/service/autopilot.go index 44f045c0b..4e3ebbae5 100644 --- a/server/internal/service/autopilot.go +++ b/server/internal/service/autopilot.go @@ -1058,6 +1058,8 @@ func (s *AutopilotService) dispatchRunOnly(ctx context.Context, ap db.Autopilot, if err != nil { return fmt.Errorf("create autopilot task: %w", err) } + // Only a manual "run now" carries HumanAction; schedule/webhook fires do not. + s.TaskSvc.RecordHumanDelegation(ctx, autopilotAttr) // Update run with task reference. updatedRun, err := s.Queries.UpdateAutopilotRunRunning(ctx, db.UpdateAutopilotRunRunningParams{ diff --git a/server/internal/service/issue.go b/server/internal/service/issue.go index d7e6b3275..2328422bf 100644 --- a/server/internal/service/issue.go +++ b/server/internal/service/issue.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/multica-ai/multica/server/internal/attribution" "log/slog" "sort" "time" @@ -550,6 +551,13 @@ func (s *IssueService) Create(ctx context.Context, p IssueCreateParams, opts Iss if opts.AssignedAgentRunFireAt.IsZero() { assignedTaskID = s.maybeEnqueueOnAssign(ctx, issue, p.CreatorType, actorID, opts.AssignedAgentRunFireAt) } + // Creating an issue already assigned to an agent is the member delegating + // work. The run itself is attributed through the creator fallback, which + // system wakeups share, so record the action here, at the create, for both + // the immediate and the deferred (channel media) path. + if assignedTaskID.Valid && p.CreatorType == "member" { + s.TaskService.RecordHumanDelegation(ctx, attribution.DirectHumanRun(p.CreatorID, attribution.EvidenceIssueAssignment, issue.ID)) + } return IssueCreateResult{Issue: issue, Attachments: attachments, Labels: labels, AssignedTaskID: assignedTaskID}, nil } @@ -995,5 +1003,12 @@ func (s *IssueService) enqueueSquadLeaderTask(ctx context.Context, issue db.Issu "squad_id", util.UUIDToString(squad.ID), "leader_id", util.UUIDToString(squad.LeaderID), "error", err) + return + } + // A member creating an issue assigned to a squad delegates the same way as + // one assigned to an agent (see Create). A trigger comment, when present, + // already recorded its author at enqueue. + if !triggerCommentID.Valid && authorType == "member" { + s.TaskService.RecordHumanDelegation(ctx, attribution.DirectHumanRun(issue.CreatorID, attribution.EvidenceIssueAssignment, issue.ID)) } } diff --git a/server/internal/service/selfhost_telemetry_activity_test.go b/server/internal/service/selfhost_telemetry_activity_test.go new file mode 100644 index 000000000..fb5e6d3fc --- /dev/null +++ b/server/internal/service/selfhost_telemetry_activity_test.go @@ -0,0 +1,226 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "github.com/multica-ai/multica/server/internal/testutil" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// These cover the self-host telemetry "delegating member" ledger +// (member_delegation_day): only a member's own action marks a day, on the day +// the action happened, and a fold into a queued run keeps every human. + +func delegationDays(t *testing.T, f principalFixture, userID string) []string { + t.Helper() + rows, err := f.Pool.Query(context.Background(), + "SELECT activity_date::text FROM member_delegation_day WHERE user_id=$1 ORDER BY activity_date", userID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var days []string + for rows.Next() { + var d string + if err := rows.Scan(&d); err != nil { + t.Fatal(err) + } + days = append(days, d) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return days +} + +// activityFixture is conditionFixture with the ledger enabled, as the server +// does when telemetry is on. +func activityFixture(t *testing.T) (principalFixture, *IssueWakeupService, pgtype.UUID, string) { + t.Helper() + f, s, issue, agent := conditionFixture(t) + s.Tasks.RecordMemberActivity = true + return f, s, issue, agent +} + +func cleanupDelegationDays(t *testing.T, f principalFixture, userIDs ...string) { + t.Helper() + for _, id := range userIDs { + f.Cleanup(t, "DELETE FROM member_delegation_day WHERE user_id=$1", id) + } +} + +func utcDate(at time.Time) string { return at.UTC().Format(time.DateOnly) } + +func assignIssueToAgent(t *testing.T, f principalFixture, issue pgtype.UUID, agent string) db.Issue { + t.Helper() + f.Exec(t, "UPDATE issue SET status='in_progress', assignee_type='agent', assignee_id=$2 WHERE id=$1", issue, agent) + row, err := f.q.GetIssue(context.Background(), issue) + if err != nil { + t.Fatal(err) + } + return row +} + +func TestSelfHostTelemetryActorAssignmentMarksToday(t *testing.T) { + f, s, issueID, agent := activityFixture(t) + cleanupDelegationDays(t, f, f.UserID) + issue := assignIssueToAgent(t, f, issueID, agent) + if _, err := s.Tasks.EnqueueTaskForIssueByActor(context.Background(), issue, parseTestUUID(t, f.UserID)); err != nil { + t.Fatal(err) + } + if got := delegationDays(t, f, f.UserID); len(got) != 1 || got[0] != utcDate(time.Now()) { + t.Fatalf("actor assignment days = %v, want [today]", got) + } +} + +// The parent issue's member creator is inherited by a child_done wakeup run as +// direct_human, but nobody acted. +func TestSelfHostTelemetryAutomaticChildDoneMustNotCountHumanActivity(t *testing.T) { + f, s, issue, agent := activityFixture(t) + ctx := context.Background() + cleanupDelegationDays(t, f, f.UserID) + f.Exec(t, "UPDATE issue SET status='in_progress', assignee_type='agent',assignee_id=$2,created_at=now()-interval '40 days' WHERE id=$1", issue, agent) + child := f.Issue(t, "child", testutil.Cols{"parent_issue_id": issue, "status": "in_progress", "creator_type": "agent", "creator_id": agent}) + f.Cleanup(t, "DELETE FROM issue_child_event WHERE parent_id=$1", issue) + if err := s.ProcessChildEvents(ctx, issue); err != nil { + t.Fatal(err) + } + f.Exec(t, "UPDATE issue SET status='done' WHERE id=$1", child) + if err := s.ProcessChildEvents(ctx, issue); err != nil { + t.Fatal(err) + } + var source string + if err := f.Pool.QueryRow(ctx, "SELECT originator_source FROM agent_task_queue WHERE issue_id=$1 AND context->>'wakeup_system'='child_done'", issue).Scan(&source); err != nil { + t.Fatalf("child_done run was not created: %v", err) + } + if got := delegationDays(t, f, f.UserID); len(got) != 0 { + t.Fatalf("automatic child_done run (source=%s) marked delegation days %v", source, got) + } +} + +func TestSelfHostTelemetryAutomaticRetryMustNotCountHumanActivity(t *testing.T) { + f, s, issueID, agent := activityFixture(t) + ctx := context.Background() + cleanupDelegationDays(t, f, f.UserID) + issue := assignIssueToAgent(t, f, issueID, agent) + parent, err := s.Tasks.EnqueueTaskForIssueByActor(ctx, issue, parseTestUUID(t, f.UserID)) + if err != nil { + t.Fatal(err) + } + // Pretend the human acted yesterday, then fail the run and retry it today. + yesterday := utcDate(time.Now().Add(-24 * time.Hour)) + f.Exec(t, "UPDATE member_delegation_day SET activity_date=$2::date WHERE user_id=$1", f.UserID, yesterday) + f.Exec(t, "UPDATE agent_task_queue SET status='failed', completed_at=now() WHERE id=$1", parent.ID) + retry, err := f.q.CreateRetryTask(ctx, db.CreateRetryTaskParams{ID: parent.ID}) + if err != nil { + t.Fatal(err) + } + if !retry.RetryOfTaskID.Valid { + t.Fatal("retry lineage missing") + } + if got := delegationDays(t, f, f.UserID); len(got) != 1 || got[0] != yesterday { + t.Fatalf("retry changed delegation days to %v, want only [%s]", got, yesterday) + } +} + +// A replies to the agent yesterday; the run is still queued today when B's reply +// folds into it and re-attributes it to B. Both humans keep their own day. +func TestSelfHostTelemetryCoalescedHumanActionsMustRetainUsersAndDates(t *testing.T) { + f, s, issueID, agent := activityFixture(t) + ctx := context.Background() + userB := f.member(t, "second human") + cleanupDelegationDays(t, f, f.UserID, userB) + issue := assignIssueToAgent(t, f, issueID, agent) + yesterdayAt := time.Now().UTC().Add(-24 * time.Hour) + + commentA := f.Comment(t, issueID.String(), "first human delegates", testutil.Cols{"created_at": yesterdayAt}) + if _, err := s.Tasks.EnqueueTaskForIssue(ctx, issue, parseTestUUID(t, commentA)); err != nil { + t.Fatal(err) + } + + commentB := f.Comment(t, issueID.String(), "second human delegates", testutil.Cols{"author_id": userB, "parent_id": commentA}) + agentRow, err := f.q.GetAgent(ctx, parseTestUUID(t, agent)) + if err != nil { + t.Fatal(err) + } + // The same steps handler.mergeCommentIntoPendingTask runs. + attr, err := s.Tasks.AttributionForMergedComment(ctx, issue.WorkspaceID, parseTestUUID(t, commentB), false, agentRow) + if err != nil { + t.Fatal(err) + } + if _, err := f.q.MergeCommentIntoPendingTask(ctx, db.MergeCommentIntoPendingTaskParams{ + IssueID: issueID, AgentID: agentRow.ID, NewTriggerCommentID: parseTestUUID(t, commentB), + NewOriginatorUserID: attr.UserID, NewAccountableUserID: attr.AccountableUserID, + NewOriginatorSource: pgtype.Text{String: attr.Source.String(), Valid: true}, + NewTriggerEvidenceKind: pgtype.Text{String: string(attr.EvidenceKind), Valid: true}, + NewTriggerEvidenceRefID: attr.EvidenceRefID, + }); err != nil { + t.Fatal(err) + } + s.Tasks.RecordHumanDelegation(ctx, attr) + + if got := delegationDays(t, f, f.UserID); len(got) != 1 || got[0] != utcDate(yesterdayAt) { + t.Errorf("A's days = %v, want [%s] (the comment's day, not the enqueue day)", got, utcDate(yesterdayAt)) + } + if got := delegationDays(t, f, userB); len(got) != 1 || got[0] != utcDate(time.Now()) { + t.Errorf("B's days = %v, want [today]", got) + } +} + +// Creating an issue already assigned to an agent is a member delegating work, +// on both the immediate and the deferred (channel media) path. +func TestSelfHostTelemetryCreateAssignedIssueMarksToday(t *testing.T) { + for _, tc := range []struct { + name string + fireAt time.Time + }{ + {"immediate", time.Time{}}, + {"deferred", time.Now().Add(time.Hour)}, + } { + t.Run(tc.name, func(t *testing.T) { + f, s, _, agent := activityFixture(t) + cleanupDelegationDays(t, f, f.UserID) + ctx := context.Background() + f.Exec(t, "UPDATE workspace SET issue_counter=(SELECT COALESCE(MAX(number),0) FROM issue WHERE workspace_id=$1) WHERE id=$1", f.WorkspaceID) + issues := NewIssueService(f.q, f.Pool, f.svc.Bus, nil, s.Tasks) + result, err := issues.Create(ctx, IssueCreateParams{ + WorkspaceID: parseTestUUID(t, f.WorkspaceID), Title: "Human creates an assigned issue", Status: "todo", Priority: "medium", + AssigneeType: pgtype.Text{String: "agent", Valid: true}, AssigneeID: parseTestUUID(t, agent), + CreatorType: "member", CreatorID: parseTestUUID(t, f.UserID), + }, IssueCreateOpts{AssignedAgentRunFireAt: tc.fireAt}) + if err != nil { + t.Fatal(err) + } + f.Cleanup(t, "DELETE FROM issue WHERE id=$1", result.Issue.ID) + f.Cleanup(t, "DELETE FROM activity_log WHERE issue_id=$1", result.Issue.ID) + f.Cleanup(t, "DELETE FROM agent_task_queue WHERE issue_id=$1", result.Issue.ID) + if !result.AssignedTaskID.Valid { + t.Fatal("no assigned run was created") + } + if got := delegationDays(t, f, f.UserID); len(got) != 1 || got[0] != utcDate(time.Now()) { + t.Fatalf("create-with-assignee days = %v, want [today]", got) + } + }) + } +} + +// With telemetry off (the server leaves RecordMemberActivity false under +// DO_NOT_TRACK) the ledger is never written. +func TestSelfHostTelemetryDisabledDoesNotRecordDelegation(t *testing.T) { + f, s, issueID, agent := conditionFixture(t) + cleanupDelegationDays(t, f, f.UserID) + if s.Tasks.RecordMemberActivity { + t.Fatal("ledger must be off unless the server enables telemetry") + } + issue := assignIssueToAgent(t, f, issueID, agent) + if _, err := s.Tasks.EnqueueTaskForIssueByActor(context.Background(), issue, parseTestUUID(t, f.UserID)); err != nil { + t.Fatal(err) + } + if got := delegationDays(t, f, f.UserID); len(got) != 0 { + t.Fatalf("telemetry disabled but ledger recorded %v", got) + } +} diff --git a/server/internal/service/task.go b/server/internal/service/task.go index f55ab8341..f7a5db9c0 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -38,6 +38,11 @@ import ( ) type TaskService struct { + // RecordMemberActivity enables the member_delegation_day ledger, which + // only feeds self-host telemetry. Off unless the server enables telemetry + // at startup, so DO_NOT_TRACK stops collection, not just delivery. + RecordMemberActivity bool + Queries *db.Queries TxStarter TxStarter Hub *realtime.Hub @@ -469,6 +474,7 @@ func (s *TaskService) attributionFromComment(ctx context.Context, comment db.Com CommentID: comment.ID, AuthorType: comment.AuthorType, AuthorID: comment.AuthorID, + CreatedAt: comment.CreatedAt, } // For an agent-authored comment, walk comment.source_task_id → parent task → // parent.originator_user_id (set by every agent comment-write path since @@ -814,6 +820,26 @@ func attributionCreateParams(attr attribution.Result) (source pgtype.Text, deleg return } +// RecordHumanDelegation notes that attr's member personally started an agent +// run, for the self-host telemetry "delegating member" counts. Call it once the +// run row (or the fold into a queued row) is committed, with the attribution +// that row was written with. Runs that only inherit a human (retries, system +// wakeups, delegation, autopilot) carry HumanAction=false and are skipped. +// +// Best-effort and outside any caller transaction: a failed write costs one +// activity mark, never the run. +func (s *TaskService) RecordHumanDelegation(ctx context.Context, attr attribution.Result) { + if s == nil || !s.RecordMemberActivity || s.Queries == nil || !attr.HumanAction || !attr.UserID.Valid { + return + } + if err := s.Queries.RecordMemberDelegationDay(ctx, db.RecordMemberDelegationDayParams{ + UserID: attr.UserID, + ActedAt: attr.HumanActedAt, + }); err != nil { + slog.Warn("record member delegation day failed", "error", err) + } +} + // OriginatorForIssueTask exposes resolveOriginatorForIssueTask to callers // outside the service package (the squad-leader access gate in the handler // layer) so the gate judges the top-of-chain human with the exact same @@ -1372,6 +1398,7 @@ func (s *TaskService) enqueueIssueTaskWithCommentPlan(ctx context.Context, issue "agent_id", util.UUIDToString(issue.AssigneeID), "force_fresh_session", forceFreshSession, ) + s.RecordHumanDelegation(ctx, attr) if fireAt.Valid { return task, nil } @@ -1508,6 +1535,7 @@ func (s *TaskService) enqueueMentionTaskWithCommentPlan(ctx context.Context, iss } slog.Info("mention task enqueued", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(issue.ID), "agent_id", util.UUIDToString(agentID), "is_leader_task", isLeader) + s.RecordHumanDelegation(ctx, attr) // See EnqueueTaskForIssue for ordering rationale. s.broadcastTaskEvent(ctx, protocol.EventTaskQueued, task) s.NotifyTaskEnqueued(ctx, task) @@ -1721,6 +1749,7 @@ func (s *TaskService) enqueueQuickCreateTask(ctx context.Context, workspaceID, r // cycle. Without this the user perceives "quick create never // triggered" because the modal closes immediately and the task // sits in 'queued' until the next sleepWithContextOrWakeup tick. + s.RecordHumanDelegation(ctx, attr) s.NotifyTaskEnqueued(ctx, task) return task, nil } @@ -2159,6 +2188,12 @@ func (s *TaskService) enqueueChatTaskTx( // FinalizeChatTaskEnqueue performs only post-commit effects. func (s *TaskService) FinalizeChatTaskEnqueue(ctx context.Context, task db.AgentTaskQueue) { + // Every chat enqueue path stamps the message sender as a direct_human + // originator (DirectHumanRun); an unresolved sender is unattributed or + // owner_fallback and is skipped here. + if task.OriginatorSource.String == string(attribution.SourceDirectHuman) && !task.RetryOfTaskID.Valid { + s.RecordHumanDelegation(ctx, attribution.Result{UserID: task.OriginatorUserID, HumanAction: true}) + } if task.Status == "deferred" { slog.Info("chat task deferred for channel media", "task_id", util.UUIDToString(task.ID), @@ -2519,6 +2554,7 @@ func (s *TaskService) SendDirectChatMessage( "task_id", util.UUIDToString(out.Task.ID), "chat_session_id", util.UUIDToString(session.ID), "agent_id", util.UUIDToString(session.AgentID)) + s.RecordHumanDelegation(ctx, attr) // Notify only after commit. See EnqueueTaskForIssue for ordering rationale. s.broadcastTaskEvent(ctx, protocol.EventTaskQueued, out.Task) s.NotifyTaskEnqueued(ctx, out.Task) diff --git a/server/migrations/589_member_delegation_day.down.sql b/server/migrations/589_member_delegation_day.down.sql new file mode 100644 index 000000000..2e31781f6 --- /dev/null +++ b/server/migrations/589_member_delegation_day.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS member_delegation_day; diff --git a/server/migrations/589_member_delegation_day.up.sql b/server/migrations/589_member_delegation_day.up.sql new file mode 100644 index 000000000..f561a27a4 --- /dev/null +++ b/server/migrations/589_member_delegation_day.up.sql @@ -0,0 +1,16 @@ +-- One row per (member, UTC day) on which that member personally started an +-- agent run: commented, mentioned, assigned, promoted, created an assigned +-- issue, triggered, reran, chatted or quick-created. Written at the moment of +-- the human action, so it keeps every human who acted and the day they acted +-- even when a later comment folds into a queued run and re-attributes it, and +-- it never records runs that only inherit a human (retries, system wakeups, +-- delegation, autopilot). Not written when telemetry is off (DO_NOT_TRACK). +-- +-- Feeds the self-host telemetry "delegating member" counts; no other reader. +-- No FK to "user" by design (workspace DB rules); readers join current +-- members. The primary key is built concurrently in 590/591, following +-- client_usage_daily (207-209), so no migration builds an index inline. +CREATE TABLE member_delegation_day ( + user_id UUID NOT NULL, + activity_date DATE NOT NULL +); diff --git a/server/migrations/590_member_delegation_day_unique_index.down.sql b/server/migrations/590_member_delegation_day_unique_index.down.sql new file mode 100644 index 000000000..fc7024f4c --- /dev/null +++ b/server/migrations/590_member_delegation_day_unique_index.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS member_delegation_day_user_date_uidx; diff --git a/server/migrations/590_member_delegation_day_unique_index.up.sql b/server/migrations/590_member_delegation_day_unique_index.up.sql new file mode 100644 index 000000000..b7f0ff286 --- /dev/null +++ b/server/migrations/590_member_delegation_day_unique_index.up.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS member_delegation_day_user_date_uidx ON member_delegation_day (user_id, activity_date); diff --git a/server/migrations/591_member_delegation_day_primary_key.down.sql b/server/migrations/591_member_delegation_day_primary_key.down.sql new file mode 100644 index 000000000..08ade674d --- /dev/null +++ b/server/migrations/591_member_delegation_day_primary_key.down.sql @@ -0,0 +1 @@ +ALTER TABLE member_delegation_day DROP CONSTRAINT IF EXISTS member_delegation_day_pkey; diff --git a/server/migrations/591_member_delegation_day_primary_key.up.sql b/server/migrations/591_member_delegation_day_primary_key.up.sql new file mode 100644 index 000000000..887b64df3 --- /dev/null +++ b/server/migrations/591_member_delegation_day_primary_key.up.sql @@ -0,0 +1 @@ +ALTER TABLE member_delegation_day ADD CONSTRAINT member_delegation_day_pkey PRIMARY KEY USING INDEX member_delegation_day_user_date_uidx; diff --git a/server/pkg/db/generated/member_delegation.sql.go b/server/pkg/db/generated/member_delegation.sql.go new file mode 100644 index 000000000..bfe53424b --- /dev/null +++ b/server/pkg/db/generated/member_delegation.sql.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: member_delegation.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const recordMemberDelegationDay = `-- name: RecordMemberDelegationDay :exec +INSERT INTO member_delegation_day (user_id, activity_date) +VALUES ( + $1, + (COALESCE($2::timestamptz, CURRENT_TIMESTAMP) AT TIME ZONE 'UTC')::date +) +ON CONFLICT DO NOTHING +` + +type RecordMemberDelegationDayParams struct { + UserID pgtype.UUID `json:"user_id"` + ActedAt pgtype.Timestamptz `json:"acted_at"` +} + +// Idempotent: one row per member per UTC day. acted_at NULL means now. +func (q *Queries) RecordMemberDelegationDay(ctx context.Context, arg RecordMemberDelegationDayParams) error { + _, err := q.db.Exec(ctx, recordMemberDelegationDay, arg.UserID, arg.ActedAt) + return err +} diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index aa8f3362f..fb908b92b 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -1218,6 +1218,11 @@ type Member struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type MemberDelegationDay struct { + UserID pgtype.UUID `json:"user_id"` + ActivityDate pgtype.Date `json:"activity_date"` +} + type NotificationPreference struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` diff --git a/server/pkg/db/queries/member_delegation.sql b/server/pkg/db/queries/member_delegation.sql new file mode 100644 index 000000000..217140804 --- /dev/null +++ b/server/pkg/db/queries/member_delegation.sql @@ -0,0 +1,8 @@ +-- name: RecordMemberDelegationDay :exec +-- Idempotent: one row per member per UTC day. acted_at NULL means now. +INSERT INTO member_delegation_day (user_id, activity_date) +VALUES ( + sqlc.arg('user_id'), + (COALESCE(sqlc.narg('acted_at')::timestamptz, CURRENT_TIMESTAMP) AT TIME ZONE 'UTC')::date +) +ON CONFLICT DO NOTHING;