From 32c512d9a19f3eaa972629b98d8887fc64fcecf9 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:36:46 +0800 Subject: [PATCH 1/2] fix(usage): accept canonical prerelease versions across telemetry Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/usage-collector/test/collector.test.mjs | 82 +++++++++++-------- .../runtime/usage_statistics_contract.ts | 7 +- .../runtime/usage_statistics_diagnostics.ts | 4 +- .../usage_statistics_installation_contract.ts | 4 +- .../control_plane_ts/usage_statistics.test.ts | 22 +++++ tests/test_usage_ping.py | 21 +++-- 6 files changed, 94 insertions(+), 46 deletions(-) diff --git a/apps/usage-collector/test/collector.test.mjs b/apps/usage-collector/test/collector.test.mjs index 20e42e2ae5..c110c4fedf 100644 --- a/apps/usage-collector/test/collector.test.mjs +++ b/apps/usage-collector/test/collector.test.mjs @@ -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(); @@ -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); diff --git a/loopx/control_plane/runtime/usage_statistics_contract.ts b/loopx/control_plane/runtime/usage_statistics_contract.ts index 69f2e3773f..9bbcacd44e 100644 --- a/loopx/control_plane/runtime/usage_statistics_contract.ts +++ b/loopx/control_plane/runtime/usage_statistics_contract.ts @@ -22,6 +22,11 @@ function keys(value: Record, 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) @@ -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) diff --git a/loopx/control_plane/runtime/usage_statistics_diagnostics.ts b/loopx/control_plane/runtime/usage_statistics_diagnostics.ts index b5ad64e9bf..4b8ede2222 100644 --- a/loopx/control_plane/runtime/usage_statistics_diagnostics.ts +++ b/loopx/control_plane/runtime/usage_statistics_diagnostics.ts @@ -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"; @@ -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; diff --git a/loopx/control_plane/runtime/usage_statistics_installation_contract.ts b/loopx/control_plane/runtime/usage_statistics_installation_contract.ts index 7079f1f334..a2556090b9 100644 --- a/loopx/control_plane/runtime/usage_statistics_installation_contract.ts +++ b/loopx/control_plane/runtime/usage_statistics_installation_contract.ts @@ -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"; @@ -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 diff --git a/tests/control_plane_ts/usage_statistics.test.ts b/tests/control_plane_ts/usage_statistics.test.ts index a4917b9133..3bb2ace3b9 100644 --- a/tests/control_plane_ts/usage_statistics.test.ts +++ b/tests/control_plane_ts/usage_statistics.test.ts @@ -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"; @@ -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); diff --git a/tests/test_usage_ping.py b/tests/test_usage_ping.py index d5a0ab1a5f..09480d830e 100644 --- a/tests/test_usage_ping.py +++ b/tests/test_usage_ping.py @@ -281,27 +281,38 @@ def test_real_cli_returns_while_http_response_is_held_and_disable_survives(isola assert str(isolated) not in json.dumps(received) -def test_real_cli_first_result_reaches_http_without_next_day_return(isolated, collector, monkeypatch): +@pytest.mark.parametrize('version', ['1.4.0', '2.0.0a1', '2.0.0b2', '2.0.0rc3']) +def test_real_cli_first_result_reaches_http_without_next_day_return(isolated, collector, monkeypatch, version): endpoint, received, accepted, release = collector monkeypatch.setenv('LOOPX_USAGE_PING_ENDPOINT', endpoint) + monkeypatch.setattr(usage_ping, '__version__', version) release.set() - setup = 'import sys; from pathlib import Path; from loopx import usage_ping; usage_ping.select_default_runtime_root=lambda: Path(sys.argv[1]); from loopx.cli_runtime import main; ' - command = [sys.executable, '-c', setup + 'raise SystemExit(main(["version", "--format", "json"]))', str(isolated)] + setup = ('import sys,loopx; from pathlib import Path; loopx.__version__=sys.argv[2]; ' + 'from loopx import usage_ping; usage_ping.select_default_runtime_root=lambda: Path(sys.argv[1]); ' + 'from loopx.cli_runtime import main; ') + command = [sys.executable, '-c', setup + 'raise SystemExit(main(["version", "--format", "json"]))', + str(isolated), version] first = subprocess.run(command, capture_output=True, text=True, timeout=30) assert first.returncode == 0 and 'random installation ID' in first.stderr assert received == [] second = subprocess.run(command, capture_output=True, text=True, timeout=30) assert second.returncode == 0 and json.loads(second.stdout) == json.loads(first.stdout) + assert json.loads(second.stdout)['version'] == version + schemas = {'loopx_usage_ping_v1', 'loopx_usage_diagnostics_v1', 'loopx_installation_usage_v1'} deadline = time.monotonic() + 5 - while time.monotonic() < deadline and not any(p['schema'] == 'loopx_usage_diagnostics_v1' for p in received): + while time.monotonic() < deadline and not schemas.issubset({p['schema'] for p in received}): time.sleep(0.02) + assert schemas.issubset({p['schema'] for p in received}), 'every versioned channel must reach HTTP' + assert next(p for p in received if p['schema'] == 'loopx_usage_ping_v1')['version'] == version + profile = next(p for p in received if p['schema'] == 'loopx_installation_usage_v1')['profiles'][0] + assert profile['version'] == version aggregates = [p for p in received if p['schema'] == 'loopx_usage_diagnostics_v1'] assert len(aggregates) == 1, 'one completed command must not depend on a next-day invocation' assert set(aggregates[0]) == {'schema', 'counters'} counters = aggregates[0]['counters'] assert len(counters) == 1 and counters[0]['feature'] == 'version' assert counters[0]['outcome'] == 'ok' and counters[0]['count'] == 1 - assert counters[0]['version'] and counters[0]['context'] == 'unknown' + assert counters[0]['version'] == version and counters[0]['context'] == 'unknown' assert usage_ping.control('status')['aggregate_preview'] is None usage_ping.control('disable') From 111917cadb0b5237ebbf9faa820cb181365d32c3 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:36:46 +0800 Subject: [PATCH 2/2] docs(usage): describe bounded prerelease version grammar Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/usage-ping.md | 13 +++++++++---- docs/reference/usage-ping.zh-CN.md | 11 +++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/reference/usage-ping.md b/docs/reference/usage-ping.md index af7ea48655..dedd13e3ef 100644 --- a/docs/reference/usage-ping.md +++ b/docs/reference/usage-ping.md @@ -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. @@ -122,8 +122,13 @@ 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): @@ -131,7 +136,7 @@ a custom version containing a private suffix is not sent. {"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): diff --git a/docs/reference/usage-ping.zh-CN.md b/docs/reference/usage-ping.zh-CN.md index 08bffeefcb..807531bca1 100644 --- a/docs/reference/usage-ping.zh-CN.md +++ b/docs/reference/usage-ping.zh-CN.md @@ -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` @@ -91,8 +91,11 @@ 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 引入): @@ -100,7 +103,7 @@ ID 随机生成,属于持久机器状态目录,不绑定账号、不从硬 {"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`,不猜企业、人数或机器拓扑,不接受公司名称。