Skip to content

Commit c1e0abe

Browse files
committed
refactor(usage): reuse typed device context validation
Signed-off-by: huangruiteng <huangrt01@163.com>
1 parent e1d4ca7 commit c1e0abe

10 files changed

Lines changed: 42 additions & 13 deletions

File tree

‎apps/presentation/dashboard/src/data/chat.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2251,6 +2251,7 @@ export async function disconnectLarkGoalTopic(goalId: string, connectionId: stri
22512251
);
22522252
}
22532253

2254+
export { CONTEXTS as usageContexts } from "../../../../../loopx/control_plane/runtime/usage_statistics_contract";
22542255
const usageStatisticsSchema = z.object({
22552256
consent: z.enum(["default", "enabled", "disabled"]),
22562257
sending: z.boolean(), blocked_by: z.string().nullable(), endpoint: z.string().nullable(),

‎apps/presentation/dashboard/src/features/personal-workspace/usage-statistics-settings.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState } from "react";
2-
import { setUsageContext, usageStatistics, type UsageStatistics } from "../../data/chat";
2+
import { setUsageContext, usageContexts, usageStatistics, type UsageStatistics } from "../../data/chat";
33
import { useWorkspaceI18n } from "./i18n";
44

55
export function UsageStatisticsSettings() {
@@ -36,7 +36,7 @@ export function UsageStatisticsSettings() {
3636
{state ? <>
3737
<label>{zh ? "这台设备的用途(自愿声明)" : "Device deployment context (voluntary)"}
3838
<select value={state.stored_context ?? "unknown"} disabled={busy} onChange={event => void updateContext(event.target.value)}>
39-
{(["unknown", "personal", "shared_service", "ephemeral", "organization_managed", "maintainer"] as const).map(value =>
39+
{usageContexts.map(value =>
4040
<option key={value} value={value}>{zh ? ({ unknown: "未知", personal: "个人使用", shared_service: "共享服务",
4141
ephemeral: "临时环境", organization_managed: "组织管理", maintainer: "项目维护者" })[value] : value}</option>)}
4242
</select>

‎loopx/chat_usage_statistics_api.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import subprocess
55
from typing import Any
66

7-
from .usage_ping import control
7+
from .usage_ping import UsageSettingsInputError, control
88

99
CHAT_USAGE_STATISTICS_PATH = "/api/chat/usage-statistics"
1010

@@ -24,8 +24,6 @@ def _usage_statistics_update(self) -> None:
2424
try:
2525
body = self._read_json()
2626
if set(body) == {"context"} and isinstance(body["context"], str):
27-
if body["context"] not in {"unknown", "personal", "shared_service", "ephemeral", "organization_managed", "maintainer"}:
28-
raise ValueError("invalid deployment context")
2927
self._usage_statistics_request("context", context=body["context"])
3028
return
3129
if set(body) == {"notice"} and isinstance(body["notice"], dict):
@@ -41,6 +39,9 @@ def _usage_statistics_update(self) -> None:
4139
def _usage_statistics_request(self, action: str, **fields: Any) -> None:
4240
try:
4341
projection = control(action, **fields)
42+
except UsageSettingsInputError as exc:
43+
self._send_error(str(exc), status=400, error_code="invalid_usage_settings")
44+
return
4445
except (OSError, ValueError, RuntimeError, TimeoutError, subprocess.TimeoutExpired):
4546
self._send_error("Usage settings unavailable; use loopx usage-ping status in the terminal.",
4647
status=503, error_code="usage_settings_unavailable")

‎loopx/cli_commands/usage_ping.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import argparse
4+
import sys
45
from collections.abc import Callable
56

67
from .. import usage_ping
@@ -49,7 +50,7 @@ def register_usage_ping_command(
4950
default="status",
5051
help="status previews payloads; enable accepts collection; disable clears the ID and pending counts.",
5152
)
52-
parser.add_argument("--context", choices=("unknown", "personal", "shared_service", "ephemeral", "organization_managed", "maintainer"),
53+
parser.add_argument("--context", metavar="CONTEXT",
5354
help="Persistent device label; only with context. Does not enable collection; environment takes precedence.")
5455
add_subcommand_format(parser)
5556
return parser
@@ -58,6 +59,10 @@ def register_usage_ping_command(
5859
def handle_usage_ping_command(args: argparse.Namespace, print_payload: PrintPayload) -> int:
5960
if (args.action == "context") != (args.context is not None):
6061
raise ValueError("use usage-ping context --context <value>; other actions take no context")
61-
payload = usage_ping.control(args.action, **({"context": args.context} if args.context is not None else {}))
62+
try:
63+
payload = usage_ping.control(args.action, **({"context": args.context} if args.context is not None else {}))
64+
except usage_ping.UsageSettingsInputError as exc:
65+
print(str(exc), file=sys.stderr)
66+
return 2
6267
print_payload(payload, output_format(args), render_usage_ping_markdown)
6368
return 0

‎loopx/control_plane/runtime/usage_statistics.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,13 @@ export async function inspect(path: string, ctx: Context) {
139139
export function effectiveContext(state: Pick<State, "context">, ctx: Context): Diagnostic["context"] {
140140
return usageContext(ctx.env.LOOPX_USAGE_CONTEXT !== undefined ? ctx.env.LOOPX_USAGE_CONTEXT : state.context);
141141
}
142+
/** Only this input rejection may cross the adapter as a caller error. */
143+
export class UsageContextInputError extends Error {
144+
constructor() { super("usage_context_invalid"); }
145+
}
142146
/** A device label is observation metadata, never consent, identity or work authority. */
143147
export async function configureContext(path: string, ctx: Context, value: unknown) {
144-
if (!(CONTEXTS as readonly unknown[]).includes(value)) throw new Error("usage_context_invalid");
148+
if (!(CONTEXTS as readonly unknown[]).includes(value)) throw new UsageContextInputError();
145149
await withFileMutationLock(path, async () => {
146150
const state = await load(path);
147151
state.generation ||= randomUUID();

‎loopx/control_plane/runtime/usage_statistics_cli.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { configure, configureContext, inspect, observe } from "./usage_statistics.ts";
1+
import { configure, configureContext, inspect, observe, UsageContextInputError } from "./usage_statistics.ts";
22
import { profileFeature } from "./usage_statistics_installation_contract.ts";
33
import { durationBucket, FEATURES, object } from "./usage_statistics_contract.ts";
44
import type { Context } from "./usage_statistics.ts";
@@ -47,8 +47,9 @@ try {
4747
}
4848
} else throw new Error("usage_request_invalid");
4949
process.stdout.write(JSON.stringify(result) + "\n");
50-
} catch {
50+
} catch (error) {
5151
// No environment, source paths or exception text in the diagnostic contract.
52-
process.stdout.write(JSON.stringify({ error: "usage_statistics_unavailable" }) + "\n");
52+
process.stdout.write(JSON.stringify({ error: error instanceof UsageContextInputError
53+
? "usage_context_invalid" : "usage_statistics_unavailable" }) + "\n");
5354
process.exitCode = 1;
5455
}

‎loopx/control_plane/runtime/usage_statistics_contract.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/** Public allowlist shared by the client and Cloudflare collector. No text slots. */
22
export const PING_SCHEMA = "loopx_usage_ping_v1";
33
export const AGGREGATE_SCHEMA = "loopx_usage_aggregate_v1";
4+
export const CONTEXTS = ["unknown", "personal", "shared_service", "ephemeral", "organization_managed", "maintainer"] as const;
45
export const FEATURES = ["status", "quota", "todo", "turn", "project", "connect", "pr-review", "version", "chat", "other"] as const;
56
export const OUTCOMES = ["ok", "failed", "cancelled"] as const;
67
export const DURATIONS = ["lt_100ms", "lt_1s", "lt_10s", "lt_60s", "gte_60s"] as const;

‎loopx/control_plane/runtime/usage_statistics_diagnostics.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/** Versioned, content-free diagnostics. This is observation, never work authority. */
2-
import { counterKey, DURATIONS, FEATURES, MAX_COUNT, MAX_ROWS, object } from "./usage_statistics_contract.ts";
2+
import { counterKey, CONTEXTS, DURATIONS, FEATURES, MAX_COUNT, MAX_ROWS, object } from "./usage_statistics_contract.ts";
33
import type { Counter } from "./usage_statistics_contract.ts";
44

55
export const DIAGNOSTIC_SCHEMA = "loopx_usage_diagnostics_v1";
6-
export const CONTEXTS = ["unknown", "personal", "shared_service", "ephemeral", "organization_managed", "maintainer"] as const;
6+
export { CONTEXTS } from "./usage_statistics_contract.ts";
77
export const DIAGNOSTIC_FEATURES = [...FEATURES, "heartbeat", "state", "agent", "memory", "capability", "maintenance"] as const;
88
export type DiagnosticFeature = typeof DIAGNOSTIC_FEATURES[number];
99
/** Fixed parser command names, not argv values or user-supplied extension names. */

‎loopx/usage_ping.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
_observation: ContextVar[dict[str, Any] | None] = ContextVar("usage_observation", default=None)
2323

2424

25+
class UsageSettingsInputError(ValueError):
26+
"""Typed input rejection from the existing TypeScript settings owner."""
27+
28+
2529
def select_operation(args: Any) -> None:
2630
"""Parser-owned operation names only; never scan argument values."""
2731
state = _observation.get()
@@ -105,6 +109,8 @@ def control(action: str, path: Path | None = None, **fields: Any) -> dict[str, A
105109
result = subprocess.run(_command(), input=json.dumps(_request(action, path or state_path(), **fields)),
106110
capture_output=True, text=True, encoding="utf-8", timeout=4, check=False)
107111
payload = json.loads(result.stdout)
112+
if isinstance(payload, dict) and payload.get("error") == "usage_context_invalid":
113+
raise UsageSettingsInputError("Invalid device deployment context; see the usage-ping reference.")
108114
if result.returncode or not isinstance(payload, dict) or "error" in payload:
109115
raise RuntimeError("Usage settings unavailable. Inspect the local usage-ping.json; disable can repair invalid state.")
110116
return payload

‎tests/test_usage_ping.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,16 @@ def test_context_setting_real_cli_and_http_share_state_without_enabling(isolated
446446
assert response.status == 403
447447
response.read()
448448
assert usage_ping.control('status')['stored_context'] == 'personal'
449+
before = usage_ping.state_path().read_bytes()
450+
assert main(['usage-ping', 'context', '--context', 'my-company', '--format', 'json']) == 2
451+
assert 'Invalid device deployment context' in capsys.readouterr().err
452+
assert usage_ping.state_path().read_bytes() == before
453+
usage_ping.state_path().write_text('invalid')
454+
connection.request('POST', path, json.dumps({'context': 'personal'}), {'Content-Type': 'application/json'})
455+
response = connection.getresponse()
456+
assert response.status == 503, 'a damaged store is not a caller input rejection'
457+
response.read()
458+
assert usage_ping.state_path().read_text() == 'invalid'
449459
finally:
450460
connection.close()
451461
server.shutdown()

0 commit comments

Comments
 (0)