Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 46 additions & 36 deletions apps/usage-collector/test/collector.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -177,43 +177,50 @@ test("installation migration is additive and preserves the existing ping history
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM installation_usage").get().n, 0);
});

test("real client HTTP to SQLite preserves interval union and fixed operator query semantics", async t => {
const db = d1(), now = at("2026-10-01");
const server = createServer(async (request, response) => {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const result = await handle(new Request("http://collector.example" + request.url, {
method: request.method, headers: request.headers, body: Buffer.concat(chunks),
}), db, now);
response.writeHead(result.status); response.end(await result.text());
for (const version of ["1.2.4", "2.0.0a1", "2.0.0b2", "2.0.0rc3"]) {
test(`real client HTTP to SQLite preserves versions, intervals and operator queries (${version})`, async t => {
const db = d1(), now = at("2026-10-01");
const server = createServer(async (request, response) => {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const result = await handle(new Request("http://collector.example" + request.url, {
method: request.method, headers: request.headers, body: Buffer.concat(chunks),
}), db, now);
response.writeHead(result.status); response.end(await result.text());
});
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
const root = await mkdtemp(join(tmpdir(), "loopx-real-collector-")), path = join(root, "usage.json");
t.after(async () => { server.closeAllConnections(); server.close(); await rm(root, { recursive: true, force: true }); });
const ctx = { env: { LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:" + server.address().port + "/v1/ping" },
version, python: "3.13", channel: "source", now };
await configureContext(path, ctx, "personal"); await configure(path, ctx, "enable");
const generation = JSON.parse(await readFile(path, "utf8")).generation;
const interval = { key: "a".repeat(64), measurement: "codex_turn", host: "codex_app",
start: now.getTime() - 60 * 60000, end: now.getTime() };
await observe(path, ctx, generation, { feature: "turn", count: 1, outcome: "ok", error: "none", duration: "lt_1s" }, undefined, interval, undefined, {
feature: "turn", operation: "default", outcome: "ok", error: "none", duration: "lt_1s", count: 1,
version, activity_day: "2026-10-01", context: "personal", signal: "none",
});
const row = db.raw.get("SELECT * FROM installation_usage");
assert.equal(row.context, "personal");
assert.equal(row.version, version);
assert.equal(db.raw.get("SELECT version FROM pings").version, version);
assert.equal(db.raw.get("SELECT version FROM diagnostic_counts").version, version);
assert.deepEqual(JSON.parse(row.runtime), [{ measurement: "codex_turn", observed_minutes: 60 }]);
// Real SQL consumer: independent mathematical oracle is one 60-minute interval, not Goal span.
const source = readFileSync(new URL("../queries/installation-usage.sql", import.meta.url), "utf8");
const queries = source.replace(/--[^\n]*/g, "").split(";").filter(sql => sql.trim());
const bindings = { from_day: "2026-10-01", through_day: "2026-10-02" };
for (const sql of queries) {
// Query uses named bind parameters; qualify against real SQLite.
assert.equal(db.raw.query(sql, bindings).length, 1);
}
const runtime = db.raw.all(
"SELECT SUM(json_extract(j.value, '$.observed_minutes')) AS minutes FROM installation_usage i, json_each(i.runtime) j",
);
assert.equal(runtime[0].minutes, 60);
});
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
const root = await mkdtemp(join(tmpdir(), "loopx-real-collector-")), path = join(root, "usage.json");
t.after(async () => { server.closeAllConnections(); server.close(); await rm(root, { recursive: true, force: true }); });
const ctx = { env: { LOOPX_USAGE_PING_ENDPOINT: "http://127.0.0.1:" + server.address().port + "/v1/ping" },
version: "1.2.4", python: "3.13", channel: "source", now };
await configureContext(path, ctx, "personal"); await configure(path, ctx, "enable");
const generation = JSON.parse(await readFile(path, "utf8")).generation;
const interval = { key: "a".repeat(64), measurement: "codex_turn", host: "codex_app",
start: now.getTime() - 60 * 60000, end: now.getTime() };
await observe(path, ctx, generation, { feature: "turn", count: 1, outcome: "ok", error: "none", duration: "lt_1s" }, undefined, interval);
const row = db.raw.get("SELECT * FROM installation_usage");
assert.equal(row.context, "personal");
assert.deepEqual(JSON.parse(row.runtime), [{ measurement: "codex_turn", observed_minutes: 60 }]);
// Real SQL consumer: independent mathematical oracle is one 60-minute interval, not Goal span.
const source = readFileSync(new URL("../queries/installation-usage.sql", import.meta.url), "utf8");
const queries = source.replace(/--[^\n]*/g, "").split(";").filter(sql => sql.trim());
const bindings = { from_day: "2026-10-01", through_day: "2026-10-02" };
for (const sql of queries) {
// Query uses named bind parameters; qualify against real SQLite.
assert.equal(db.raw.query(sql, bindings).length, 1);
}
const runtime = db.raw.all(
"SELECT SUM(json_extract(j.value, '$.observed_minutes')) AS minutes FROM installation_usage i, json_each(i.runtime) j",
);
assert.equal(runtime[0].minutes, 60);
});

}

test("v1 heartbeat has architecture; aggregates reject identifiers and store only counters", async () => {
const db = d1();
Expand All @@ -222,6 +229,9 @@ test("v1 heartbeat has architecture; aggregates reject identifiers and store onl
});
const heartbeat = { ...ping(1), schema: "loopx_usage_ping_v1", arch: "arm64" };
assert.equal((await handle(request("/v1/ping", heartbeat), db)).status, 204);
for (const version of ["2.0.0a1/private", "2.0.0+private", "2.0.0a1\n"]) {
assert.equal((await handle(request("/v1/ping", { ...heartbeat, version }), db)).status, 400);
}
const row = { feature: "todo", outcome: "ok", duration: "lt_1s", error: "none", count: 6 };
const aggregate = { schema: "loopx_usage_aggregate_v1", counters: [row] };
assert.equal((await handle(request("/v1/aggregate", { ...aggregate, install_id: id(1) }), db)).status, 400);
Expand Down
13 changes: 9 additions & 4 deletions docs/reference/usage-ping.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ is auto-loaded. Existing ID-free diagnostic rows remain ID-free.

The **new** `POST /v1/installation` contract,
`loopx_installation_usage_v1`, links the existing random installation ID to up
to eight UTC daily snapshots. Each has activity date, numeric version,
to eight UTC daily snapshots. Each has activity date, release version,
voluntary context, revision, fixed CLI family counts, independent observed
runtime minutes, and a truncation flag. The context and version freeze on that
day's first observation; later settings do not relabel that day or history.
Expand Down Expand Up @@ -122,16 +122,21 @@ from hardware or an account. Sessions and disclosure upgrades retain it; explici
disable deletes it. Ephemeral homes, deleted/copied state and re-enabling distort
installation counts. It enables cross-day association, so this is **not fully anonymous**.
OS is `darwin|linux|windows|other`, CPU is `x64|arm64|x86|other`, and channel is
`pip|local_release|source|unknown`. Version accepts only numeric major.minor.patch;
a custom version containing a private suffix is not sent.
`pip|local_release|source|unknown`. Heartbeat, diagnostics and installation profiles
share one version grammar: numeric `major.minor.patch`, optionally followed by a
canonical Python prerelease such as `2.0.0a1`, `2.0.0b2` or `2.0.0rc3`. The numeric
components retain their 3/3/4-digit bounds; the prerelease counter has 1–4 digits.
The exact label is retained, so a prerelease never becomes a stable release in
statistics. Custom/local suffixes, paths and surrounding whitespace are rejected.
The legacy v0 contract is unchanged.

**ID-free CLI diagnostics** (`POST /v1/aggregate`, introduced in notice revision 5):

```json
{"schema":"loopx_usage_diagnostics_v1","counters":[{"feature":"pr-review","operation":"merge-readiness","outcome":"blocked","error":"not_ready","duration":"lt_1s","count":4,"version":"1.2.3","activity_day":"2026-09-30","context":"unknown","signal":"none"}]}
```

The new default adds numeric release version, UTC activity **date** (not event
The new default adds release version, UTC activity **date** (not event
time), fixed sub-operation, result/reason and receipt-backed lifecycle signal.
Deployment context is optional self-report via the shared device setting or
`LOOPX_USAGE_CONTEXT` (environment takes precedence):
Expand Down
11 changes: 7 additions & 4 deletions docs/reference/usage-ping.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Workspace 设备设置复用同一个 TypeScript owner。优先级是显式
关闭统计清除标识与测量记录,但保留自愿用途标签。

新增 `POST /v1/installation`,契约 `loopx_installation_usage_v1`。
同一个随机安装 ID 会与每日固定 CLI 功能计数、数字版本、UTC 活动日期、自愿环境
同一个随机安装 ID 会与每日固定 CLI 功能计数、发布版本、UTC 活动日期、自愿环境
标签、已观测运行分钟、快照 revision 和截断标志关联。每天首次观察后固定该日
标签和版本,后续配置不能反向改写历史。这是**新增关联**,不是匿名汇总。
CLI 诊断及安装概要增加 `heartbeat|state|agent|memory|capability|maintenance`
Expand Down Expand Up @@ -91,16 +91,19 @@ ID 随机生成,属于持久机器状态目录,不绑定账号、不从硬
告知升级保留 ID,明确关闭删除 ID。临时 home、删/复制状态或重新开启会影响统计。
它能跨天关联,因此不能称为完全匿名。
系统只允许 `darwin|linux|windows|other`,架构只允许 `x64|arm64|x86|other`,
安装渠道只允许 `pip|local_release|source|unknown`。版本只接受数字三段式,包含
自定义后缀的版本不会上传。
安装渠道只允许 `pip|local_release|source|unknown`。心跳、诊断和每日安装概要共用
同一版本语法:数字三段式 `major.minor.patch`,可带规范 Python 预发布标签,
例如 `2.0.0a1`、`2.0.0b2`、`2.0.0rc3`。三个数字分量仍分别最多 3/3/4 位,
预发布序号为 1–4 位。保留原标签,不把预发布版本归成正式版;自定义或本地
后缀、路径及首尾空白均拒绝。旧 v0 契约不变。

无 ID CLI 诊断 `POST /v1/aggregate`(最初随告知版本 5 引入):

```json
{"schema":"loopx_usage_diagnostics_v1","counters":[{"feature":"pr-review","operation":"merge-readiness","outcome":"blocked","error":"not_ready","duration":"lt_1s","count":4,"version":"1.2.3","activity_day":"2026-09-30","context":"unknown","signal":"none"}]}
```

默认新增数字三段版本、UTC 活动**日期**(非事件时间)、固定子操作、结果/原因及
默认新增发布版本、UTC 活动**日期**(非事件时间)、固定子操作、结果/原因及
回执支持的生命周期信号。环境类型由设备设置或优先级更高的 `LOOPX_USAGE_CONTEXT` 自愿声明:
`unknown`(默认)、`personal`、`shared_service`、`ephemeral`、`organization_managed`、
`maintainer`;无效值成为 `unknown`,不猜企业、人数或机器拓扑,不接受公司名称。
Expand Down
7 changes: 6 additions & 1 deletion loopx/control_plane/runtime/usage_statistics_contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ function keys(value: Record<string, unknown>, fields: string[]): boolean {
return Object.keys(value).sort().join() === fields.sort().join();
}
export function validId(value: unknown): value is string { return typeof value === "string" && UUID.test(value); }
/** Fixed wire grammar, shared by client and collector; preserve the release label. */
export function validUsageVersion(value: unknown): value is string {
return typeof value === "string"
&& /^\d{1,3}\.\d{1,3}\.\d{1,4}(?:(?:a|b|rc)\d{1,4})?$/.exec(value)?.[0] === value;
}
export function validCounter(value: unknown): value is Counter {
if (!object(value) || !keys(value, ["feature", "outcome", "duration", "error", "count"])) return false;
if (!(FEATURES as readonly unknown[]).includes(value.feature) || !(OUTCOMES as readonly unknown[]).includes(value.outcome)
Expand All @@ -42,7 +47,7 @@ export function validAggregate(value: unknown): value is Aggregate {
export function validPing(value: unknown): value is Ping {
return object(value) && keys(value, ["schema", "install_id", "version", "os", "arch", "python", "channel"])
&& value.schema === PING_SCHEMA && validId(value.install_id)
&& typeof value.version === "string" && /^\d{1,3}\.\d{1,3}\.\d{1,4}$/.test(value.version)
&& validUsageVersion(value.version)
&& ["darwin", "linux", "windows", "other"].includes(String(value.os))
&& ["x64", "arm64", "x86", "other"].includes(String(value.arch))
&& typeof value.python === "string" && /^3\.\d{1,2}$/.test(value.python)
Expand Down
4 changes: 2 additions & 2 deletions loopx/control_plane/runtime/usage_statistics_diagnostics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** Versioned, content-free diagnostics. This is observation, never work authority. */
import { counterKey, CONTEXTS, DURATIONS, FEATURES, MAX_COUNT, MAX_ROWS, object } from "./usage_statistics_contract.ts";
import { counterKey, CONTEXTS, DURATIONS, FEATURES, MAX_COUNT, MAX_ROWS, object, validUsageVersion } from "./usage_statistics_contract.ts";
import type { Counter } from "./usage_statistics_contract.ts";

export const DIAGNOSTIC_SCHEMA = "loopx_usage_diagnostics_v1";
Expand Down Expand Up @@ -49,7 +49,7 @@ export function validDiagnostic(value: unknown): value is Diagnostic {
if (!(DIAGNOSTIC_FEATURES as readonly unknown[]).includes(value.feature) || !(DURATIONS as readonly unknown[]).includes(value.duration)
|| !(DIAGNOSTIC_OPERATIONS as readonly unknown[]).includes(value.operation) || !(SIGNALS as readonly unknown[]).includes(value.signal)
|| !(CONTEXTS as readonly unknown[]).includes(value.context) || !(REASONS as readonly unknown[]).includes(value.error)
|| typeof value.version !== "string" || !/^\d{1,3}\.\d{1,3}\.\d{1,4}$/.test(value.version)
|| !validUsageVersion(value.version)
|| typeof value.activity_day !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value.activity_day)
|| !Number.isFinite(Date.parse(value.activity_day)) || new Date(value.activity_day).toISOString().slice(0, 10) !== value.activity_day
|| !Number.isInteger(value.count) || Number(value.count) < 1 || Number(value.count) > MAX_COUNT) return false;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** Installation-linked daily observations. Never uptime, billing or work authority. */
import { object, validId, MAX_COUNT } from "./usage_statistics_contract.ts";
import { object, validId, validUsageVersion, MAX_COUNT } from "./usage_statistics_contract.ts";
import { CONTEXTS, DIAGNOSTIC_FEATURES, diagnosticFeature } from "./usage_statistics_diagnostics.ts";
import { MEASUREMENTS } from "./usage_statistics_goal_contract.ts";

Expand All @@ -20,7 +20,7 @@ export function validInstallationDay(value: unknown): value is InstallationDay {
if (!object(value) || !exact(value, "activity_day,cli,context,revision,runtime,truncated,version")
|| typeof value.activity_day !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value.activity_day)
|| !Number.isFinite(Date.parse(value.activity_day)) || new Date(value.activity_day).toISOString().slice(0, 10) !== value.activity_day
|| typeof value.version !== "string" || !/^\d{1,3}\.\d{1,3}\.\d{1,4}$/.test(value.version)
|| !validUsageVersion(value.version)
|| !(CONTEXTS as readonly unknown[]).includes(value.context) || typeof value.truncated !== "boolean"
|| !Number.isSafeInteger(value.revision) || Number(value.revision) < 1
|| !Array.isArray(value.cli) || value.cli.length > PROFILE_FEATURES.length
Expand Down
22 changes: 22 additions & 0 deletions tests/control_plane_ts/usage_statistics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { configure, inspect, observe, endpoint } from "../../loopx/control_plane
import type { Context, Post } from "../../loopx/control_plane/runtime/usage_statistics.ts";
import { validAggregate, validPing, AGGREGATE_SCHEMA, durationBucket } from "../../loopx/control_plane/runtime/usage_statistics_contract.ts";
import type { Counter } from "../../loopx/control_plane/runtime/usage_statistics_contract.ts";
import { validDiagnostic } from "../../loopx/control_plane/runtime/usage_statistics_diagnostics.ts";
import { validInstallationDay } from "../../loopx/control_plane/runtime/usage_statistics_installation_contract.ts";
import { acquireFileMutationLock, releaseFileMutationLock } from "../../loopx/control_plane/effect_runtime_io.ts";
import type { FileMutationLock } from "../../loopx/control_plane/effect_runtime_io.ts";

Expand All @@ -24,6 +26,26 @@ async function fixture(t: test.TestContext) {
}
const noPost: Post = async () => { assert.fail("must not send"); };

test("current payloads share bounded release and canonical prerelease versions", () => {
const accepted = ["1.4.0", "2.0.0a1", "2.0.0b2", "2.0.0rc3", "999.999.9999rc9999"];
const rejected: unknown[] = [null, 200, {}, "2.0", "2.0.0a", "2.0.0alpha1", "2.0.0A1",
"2.0.0-rc.1", "2.0.0+private", "2.0.0a10000", "1000.0.0", "1.1000.0", "1.0.10000",
"2.0.0a1/private", " 2.0.0a1", "2.0.0a1\n", "1.4.0\n", "2.0.0a1" + "x".repeat(4096)];
for (const [versions, expected] of [[accepted, true], [rejected, false]] as const) {
for (const version of versions) {
const ping = { schema: "loopx_usage_ping_v1", install_id: "00000000-0000-4000-8000-000000000001",
version, os: "linux", arch: "x64", python: "3.11", channel: "source" };
const diagnostic = { ...row, operation: "default", signal: "none", version,
activity_day: "2026-10-14", context: "unknown" };
const profile = { activity_day: "2026-10-14", version, context: "unknown", revision: 1,
cli: [], runtime: [], truncated: false };
assert.equal(validPing(ping), expected, `heartbeat version ${String(version)}`);
assert.equal(validDiagnostic(diagnostic), expected, `diagnostic version ${String(version)}`);
assert.equal(validInstallationDay(profile), expected, `installation version ${String(version)}`);
}
}
});

test("fresh default requires notice; status is read-only; acknowledgment enables both channels", async t => {
const { path, state } = await fixture(t); const ctx = context();
const initial = await inspect(path, ctx);
Expand Down
Loading
Loading