@danypops/jittor 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -5
- package/docs/PROVIDER_RESEARCH.md +21 -2
- package/extension/src/benchmark-tui.ts +7 -5
- package/extension/src/footer.ts +38 -22
- package/extension/src/index.ts +73 -9
- package/extension/src/tui.ts +29 -2
- package/extension/src/usage.ts +24 -15
- package/package.json +5 -1
- package/src/adapters/openrouter-benchmark-index-source.ts +2 -1
- package/src/adapters/openrouter-design-arena-source.ts +105 -0
- package/src/adapters/sqlite-metric-store.ts +43 -2
- package/src/cli.ts +139 -10
- package/src/client.ts +12 -44
- package/src/constants.ts +31 -4
- package/src/daemon.ts +52 -47
- package/src/db.ts +13 -30
- package/src/domain/metric.ts +1 -1
- package/src/domain/model-observation.ts +41 -19
- package/src/domain/model-ranking-service.ts +3 -2
- package/src/domain/model-ranking.ts +31 -12
- package/src/domain/task-cost.ts +70 -0
- package/src/domain/task-focus.ts +65 -0
- package/src/domain/usage.ts +80 -56
- package/src/log.ts +28 -0
- package/src/ports/metric-store.ts +30 -0
- package/src/providers/anthropic-contracts.ts +22 -11
- package/src/providers/google-adc-auth.ts +63 -0
- package/src/providers/google-vertex-budget-contracts.ts +181 -0
- package/src/providers/google-vertex-budget.ts +127 -0
- package/src/providers/google-vertex-contracts.ts +14 -2
- package/src/providers/telemetry-sources.ts +35 -0
- package/src/service.ts +80 -22
- package/src/state.ts +31 -57
- package/src/version.ts +2 -14
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { BudgetWindow } from "../policy.ts";
|
|
2
|
+
import type { MetricObservation } from "../domain/metric.ts";
|
|
3
|
+
import {
|
|
4
|
+
googleVertexBudgetMetrics,
|
|
5
|
+
googleVertexBudgetWindow,
|
|
6
|
+
parseGoogleVertexBudgetNotification,
|
|
7
|
+
type GoogleVertexBudgetNotification,
|
|
8
|
+
} from "./google-vertex-budget-contracts.ts";
|
|
9
|
+
import type { GoogleVertexMetricSource } from "./google-vertex-contracts.ts";
|
|
10
|
+
import type { GoogleAdcTokenProvider } from "./google-adc-auth.ts";
|
|
11
|
+
import { GOOGLE_VERTEX_BUDGET_MAX_MESSAGES_PER_PULL } from "../constants.ts";
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
googleVertexBudgetMetrics,
|
|
15
|
+
googleVertexBudgetWindow,
|
|
16
|
+
parseGoogleVertexBudgetNotification,
|
|
17
|
+
type GoogleVertexBudgetAmountType,
|
|
18
|
+
type GoogleVertexBudgetNotification,
|
|
19
|
+
} from "./google-vertex-budget-contracts.ts";
|
|
20
|
+
|
|
21
|
+
const PUBSUB_BASE_URL = "https://pubsub.googleapis.com/v1";
|
|
22
|
+
export const GOOGLE_PUBSUB_READONLY_SCOPE = "https://www.googleapis.com/auth/pubsub";
|
|
23
|
+
|
|
24
|
+
export type GoogleVertexBudgetTransport = (request: Request) => Promise<Response>;
|
|
25
|
+
|
|
26
|
+
export interface GoogleVertexBudgetSnapshot {
|
|
27
|
+
notification: GoogleVertexBudgetNotification;
|
|
28
|
+
metrics: MetricObservation[];
|
|
29
|
+
window: BudgetWindow | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface RawPubSubMessage {
|
|
33
|
+
ackId?: unknown;
|
|
34
|
+
message?: { data?: unknown; publishTime?: unknown; attributes?: Record<string, unknown> };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SUBSCRIPTION_NAME_PATTERN = /^projects\/[^/]+\/subscriptions\/[^/]+$/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pulls (never pushes -- Jittor is a local loopback-only daemon with no public inbound endpoint)
|
|
41
|
+
* the individual GCP project's budget-notification Pub/Sub subscription, and turns Cloud
|
|
42
|
+
* Billing's own documented notification payload into Jittor's normalized metrics/BudgetWindow
|
|
43
|
+
* shape. One-time setup outside Jittor (create the topic, connect it to the budget, create a pull
|
|
44
|
+
* subscription) is required first -- see docs/PROVIDER_RESEARCH.md.
|
|
45
|
+
*/
|
|
46
|
+
export class GoogleVertexBudgetTelemetryAdapter {
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly subscription: string,
|
|
49
|
+
private readonly tokenProvider: GoogleAdcTokenProvider,
|
|
50
|
+
private readonly transport: GoogleVertexBudgetTransport = fetch,
|
|
51
|
+
private readonly source: GoogleVertexMetricSource = "google-vertex",
|
|
52
|
+
) {
|
|
53
|
+
if (!SUBSCRIPTION_NAME_PATTERN.test(subscription)) {
|
|
54
|
+
throw new Error("Google Vertex budget subscription must be of the form projects/{project}/subscriptions/{subscription}");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Pulls the pending notifications, acknowledges every message it received (Pub/Sub pull
|
|
60
|
+
* subscriptions redeliver un-acked messages forever, and Cloud Billing publishes multiple
|
|
61
|
+
* times per day regardless of whether Jittor is running -- an un-drained subscription would
|
|
62
|
+
* grow without bound), and returns the freshest successfully-parsed notification by the
|
|
63
|
+
* message's own `publishTime`. Throws (fail closed, matching every other Jittor provider's
|
|
64
|
+
* schema-drift contract) if any pulled message fails to parse, after acknowledging it so a
|
|
65
|
+
* single malformed message cannot wedge every future poll.
|
|
66
|
+
*/
|
|
67
|
+
async pull(observedAt = Date.now()): Promise<GoogleVertexBudgetSnapshot | null> {
|
|
68
|
+
const token = await this.tokenProvider();
|
|
69
|
+
const pullResponse = await this.request(":pull", token, { maxMessages: GOOGLE_VERTEX_BUDGET_MAX_MESSAGES_PER_PULL });
|
|
70
|
+
const body = await pullResponse.json() as { receivedMessages?: RawPubSubMessage[] };
|
|
71
|
+
const received = Array.isArray(body.receivedMessages) ? body.receivedMessages : [];
|
|
72
|
+
if (received.length === 0) return null;
|
|
73
|
+
|
|
74
|
+
const ackIds = received.map((entry) => entry.ackId).filter((id): id is string => typeof id === "string" && id.length > 0);
|
|
75
|
+
let parseFailure: unknown;
|
|
76
|
+
const parsed: GoogleVertexBudgetNotification[] = [];
|
|
77
|
+
for (const entry of received) {
|
|
78
|
+
try {
|
|
79
|
+
parsed.push(this.parseMessage(entry));
|
|
80
|
+
} catch (error) {
|
|
81
|
+
parseFailure = error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (ackIds.length > 0) await this.acknowledge(token, ackIds);
|
|
85
|
+
if (parseFailure) throw parseFailure;
|
|
86
|
+
if (parsed.length === 0) return null;
|
|
87
|
+
|
|
88
|
+
const freshest = parsed.reduce((latest, candidate) => candidate.publishedAt > latest.publishedAt ? candidate : latest);
|
|
89
|
+
return {
|
|
90
|
+
notification: freshest,
|
|
91
|
+
metrics: googleVertexBudgetMetrics(freshest, observedAt, this.source),
|
|
92
|
+
window: googleVertexBudgetWindow(freshest, observedAt, this.source),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private parseMessage(entry: RawPubSubMessage): GoogleVertexBudgetNotification {
|
|
97
|
+
const data = entry.message?.data;
|
|
98
|
+
if (typeof data !== "string" || data.length === 0) throw new Error("Google Vertex budget notification schema changed: message.data");
|
|
99
|
+
const publishTime = entry.message?.publishTime;
|
|
100
|
+
if (typeof publishTime !== "string") throw new Error("Google Vertex budget notification schema changed: message.publishTime");
|
|
101
|
+
const publishedAt = Date.parse(publishTime);
|
|
102
|
+
if (Number.isNaN(publishedAt)) throw new Error("Google Vertex budget notification schema changed: message.publishTime is not RFC 3339");
|
|
103
|
+
let decoded: unknown;
|
|
104
|
+
try {
|
|
105
|
+
decoded = JSON.parse(Buffer.from(data, "base64").toString("utf8"));
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error("Google Vertex budget notification schema changed: message.data is not valid base64 JSON");
|
|
108
|
+
}
|
|
109
|
+
return parseGoogleVertexBudgetNotification(decoded, entry.message?.attributes ?? {}, publishedAt);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async acknowledge(token: string, ackIds: string[]): Promise<void> {
|
|
113
|
+
// Best-effort: a failed ack only causes redelivery after the ack deadline, which the next
|
|
114
|
+
// poll will drain again; it must never fail the poll that already extracted real metrics.
|
|
115
|
+
await this.request(":acknowledge", token, { ackIds }).catch(() => undefined);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async request(action: ":pull" | ":acknowledge", token: string, body: Record<string, unknown>): Promise<Response> {
|
|
119
|
+
const response = await this.transport(new Request(`${PUBSUB_BASE_URL}/${this.subscription}${action}`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
122
|
+
body: JSON.stringify(body),
|
|
123
|
+
}));
|
|
124
|
+
if (!response.ok) throw new Error(`Google Cloud Pub/Sub ${action.slice(1)} failed with HTTP ${response.status}`);
|
|
125
|
+
return response;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -86,10 +86,22 @@ export function classifyGoogleVertexFailure(value: unknown, metadata: GoogleVert
|
|
|
86
86
|
return { kind: "unknown", transient: false, ...base };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Also reused for the third-party `anthropic-vertex` provider (Anthropic Claude models served
|
|
91
|
+
* through Google Vertex, e.g. via `@twogiants/pi-anthropic-vertex`): real-world reports show its
|
|
92
|
+
* 429s still carry GCP's own quota-exceeded message shape
|
|
93
|
+
* (`aiplatform.googleapis.com/online_prediction_requests_per_base_model`) even through Anthropic's
|
|
94
|
+
* own official Vertex SDK client, since Google's quota enforcement happens at the infra layer
|
|
95
|
+
* regardless of client wire format. `source` keeps its metrics distinguishable from Pi's native
|
|
96
|
+
* `google-vertex` provider (a different, unrelated Vertex route) and from direct Anthropic (a
|
|
97
|
+
* different account/quota pool at Anthropic's origin).
|
|
98
|
+
*/
|
|
99
|
+
export type GoogleVertexMetricSource = "google-vertex" | "anthropic-vertex";
|
|
100
|
+
|
|
89
101
|
/** A bounded failure-count observation; never a fabricated remaining-budget fraction. */
|
|
90
|
-
export function googleVertexFailureMetrics(failure: GoogleVertexFailure, observedAt: number): MetricObservation[] {
|
|
102
|
+
export function googleVertexFailureMetrics(failure: GoogleVertexFailure, observedAt: number, source: GoogleVertexMetricSource = "google-vertex"): MetricObservation[] {
|
|
91
103
|
return [{
|
|
92
|
-
source
|
|
104
|
+
source,
|
|
93
105
|
scope: "failure",
|
|
94
106
|
metric: failure.kind,
|
|
95
107
|
value: 1,
|
|
@@ -2,6 +2,9 @@ import type { BudgetWindow } from "../policy.ts";
|
|
|
2
2
|
import type { TelemetryBatch, TelemetrySource } from "../ports/telemetry-source.ts";
|
|
3
3
|
import { CodexSubscriptionTelemetryAdapter, loadCodexFileCredentials, type CodexRateLimitSnapshot, type CodexWindow, type CodexTransport } from "./codex.ts";
|
|
4
4
|
import { OpenRouterTelemetryAdapter, type OpenRouterTransport } from "./openrouter.ts";
|
|
5
|
+
import { GoogleVertexBudgetTelemetryAdapter, type GoogleVertexBudgetTransport } from "./google-vertex-budget.ts";
|
|
6
|
+
import type { GoogleVertexMetricSource } from "./google-vertex-contracts.ts";
|
|
7
|
+
import type { GoogleAdcTokenProvider } from "./google-adc-auth.ts";
|
|
5
8
|
|
|
6
9
|
function budgetWindow(
|
|
7
10
|
limit: CodexRateLimitSnapshot,
|
|
@@ -67,3 +70,35 @@ export class OpenRouterTelemetrySource implements TelemetrySource {
|
|
|
67
70
|
return { observedAt, metrics: snapshot.metrics, windows: [] };
|
|
68
71
|
}
|
|
69
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Optional (never `required`): the one-time GCP setup (Pub/Sub topic + pull subscription
|
|
76
|
+
* connected to the individual project's budget) lives entirely outside Jittor, so a subscription
|
|
77
|
+
* that doesn't exist yet, or a project not yet migrated onto the individual-project model, must
|
|
78
|
+
* not block every other route the way a missing required source would.
|
|
79
|
+
*/
|
|
80
|
+
export class GoogleVertexBudgetTelemetrySource implements TelemetrySource {
|
|
81
|
+
readonly id: string;
|
|
82
|
+
readonly provider = "google-vertex";
|
|
83
|
+
readonly required = false;
|
|
84
|
+
|
|
85
|
+
private readonly adapter: GoogleVertexBudgetTelemetryAdapter;
|
|
86
|
+
|
|
87
|
+
constructor(
|
|
88
|
+
subscription: string,
|
|
89
|
+
tokenProvider: GoogleAdcTokenProvider,
|
|
90
|
+
private readonly clock: () => number = Date.now,
|
|
91
|
+
transport: GoogleVertexBudgetTransport = fetch,
|
|
92
|
+
source: GoogleVertexMetricSource = "google-vertex",
|
|
93
|
+
) {
|
|
94
|
+
this.id = `google-vertex-budget:${source}`;
|
|
95
|
+
this.adapter = new GoogleVertexBudgetTelemetryAdapter(subscription, tokenProvider, transport, source);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async poll(): Promise<TelemetryBatch> {
|
|
99
|
+
const observedAt = this.clock();
|
|
100
|
+
const snapshot = await this.adapter.pull(observedAt);
|
|
101
|
+
if (!snapshot) return { observedAt, metrics: [], windows: [] };
|
|
102
|
+
return { observedAt, metrics: snapshot.metrics, windows: snapshot.window ? [snapshot.window] : [] };
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { errorResponse, healthResponse, readyResponse, requireBearerToken } from "@danypops/daemon-kit/http";
|
|
2
|
+
import { COMPACTION_DURATION_ESTIMATE_MAX_SAMPLES, CONTEXT_ASSESSMENT_DEFAULT_WINDOW_MS, CONTEXT_ASSESSMENT_QUERY_LIMIT, MAX_USAGE_BUCKETS, PRUNE_MIN_AGE_MS, SERVICE_MAX_BODY_BYTES, SERVICE_MAX_RESPONSE_BYTES, TASK_COST_QUERY_LIMIT, USAGE_MAX_DISTINCT_SCOPES } from "./constants.ts";
|
|
2
3
|
import { VERSION } from "./version.ts";
|
|
3
4
|
import { validateMetricObservation, type MetricObservation, type MetricQuery, type StoredMetricObservation } from "./domain/metric.ts";
|
|
4
5
|
import { assessContextTelemetry, estimateCompactionDuration, type CompactionDurationEstimate, type ContextAssessment } from "./domain/context-telemetry.ts";
|
|
6
|
+
import { buildTaskCostSummary, type TaskCostSummary } from "./domain/task-cost.ts";
|
|
7
|
+
import type { UsageAggregateRow } from "./domain/usage.ts";
|
|
5
8
|
import type { BenchmarkQuery, BenchmarkQueryResult, BenchmarkRefreshResult } from "./domain/benchmark.ts";
|
|
6
9
|
import type { ModelRanker, ModelRecommendationInput } from "./domain/model-ranking-service.ts";
|
|
7
10
|
import type { ModelRankingResult } from "./domain/model-ranking.ts";
|
|
@@ -13,6 +16,9 @@ import type { PolicyDecision, Route } from "./policy.ts";
|
|
|
13
16
|
export const EXPECTED_OPERATION_NAMES = [
|
|
14
17
|
"metrics.record",
|
|
15
18
|
"metrics.query",
|
|
19
|
+
"metrics.distinct_scopes",
|
|
20
|
+
"metrics.usage_series",
|
|
21
|
+
"metrics.cost_by_task",
|
|
16
22
|
"metrics.prune",
|
|
17
23
|
"benchmark.refresh",
|
|
18
24
|
"benchmark.status",
|
|
@@ -36,7 +42,10 @@ export type OperationName = typeof EXPECTED_OPERATION_NAMES[number];
|
|
|
36
42
|
export interface OperationInputs {
|
|
37
43
|
"metrics.record": MetricObservation;
|
|
38
44
|
"metrics.query": MetricQuery;
|
|
39
|
-
"metrics.
|
|
45
|
+
"metrics.distinct_scopes": { source: string; since: number; until: number; limit?: number };
|
|
46
|
+
"metrics.usage_series": { source: string; since: number; until: number; bucketSizeMs: number; bucketCount: number; scopeLimit?: number };
|
|
47
|
+
"metrics.cost_by_task": { since: number; until: number };
|
|
48
|
+
"metrics.prune": { before: number; force?: boolean };
|
|
40
49
|
"benchmark.refresh": { force?: boolean };
|
|
41
50
|
"benchmark.status": Record<string, never>;
|
|
42
51
|
"benchmark.query": BenchmarkQuery;
|
|
@@ -57,6 +66,9 @@ export interface OperationInputs {
|
|
|
57
66
|
export interface OperationOutputs {
|
|
58
67
|
"metrics.record": StoredMetricObservation;
|
|
59
68
|
"metrics.query": StoredMetricObservation[];
|
|
69
|
+
"metrics.distinct_scopes": string[];
|
|
70
|
+
"metrics.usage_series": { rows: UsageAggregateRow[]; truncated: boolean };
|
|
71
|
+
"metrics.cost_by_task": TaskCostSummary;
|
|
60
72
|
"metrics.prune": { deleted: number };
|
|
61
73
|
"benchmark.refresh": BenchmarkRefreshResult;
|
|
62
74
|
"benchmark.status": BenchmarkRefreshResult;
|
|
@@ -119,9 +131,61 @@ export class JittorService {
|
|
|
119
131
|
switch (operation) {
|
|
120
132
|
case "metrics.record": return this.metrics.record(validateMetricObservation(input));
|
|
121
133
|
case "metrics.query": return this.metrics.query(input as MetricQuery);
|
|
134
|
+
case "metrics.distinct_scopes": {
|
|
135
|
+
const source = input["source"];
|
|
136
|
+
const since = input["since"];
|
|
137
|
+
const until = input["until"];
|
|
138
|
+
if (typeof source !== "string" || source.length === 0) throw new Error("source is required");
|
|
139
|
+
if (!Number.isSafeInteger(since) || !Number.isSafeInteger(until) || (since as number) < 0 || (until as number) < (since as number)) {
|
|
140
|
+
throw new Error("distinct scopes requires non-negative ordered integer bounds");
|
|
141
|
+
}
|
|
142
|
+
const requestedLimit = input["limit"];
|
|
143
|
+
const limit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(USAGE_MAX_DISTINCT_SCOPES, Math.floor(requestedLimit as number))) : USAGE_MAX_DISTINCT_SCOPES;
|
|
144
|
+
return this.metrics.distinctScopes({ source, since: since as number, until: until as number, limit });
|
|
145
|
+
}
|
|
146
|
+
case "metrics.usage_series": {
|
|
147
|
+
const source = input["source"];
|
|
148
|
+
const since = input["since"];
|
|
149
|
+
const until = input["until"];
|
|
150
|
+
const bucketSizeMs = input["bucketSizeMs"];
|
|
151
|
+
const bucketCount = input["bucketCount"];
|
|
152
|
+
if (typeof source !== "string" || source.length === 0) throw new Error("source is required");
|
|
153
|
+
if (!Number.isSafeInteger(since) || !Number.isSafeInteger(until) || (since as number) < 0 || (until as number) < (since as number)) {
|
|
154
|
+
throw new Error("usage series requires non-negative ordered integer bounds");
|
|
155
|
+
}
|
|
156
|
+
if (typeof bucketSizeMs !== "number" || !Number.isFinite(bucketSizeMs) || bucketSizeMs <= 0) throw new Error("bucketSizeMs must be a positive number");
|
|
157
|
+
if (!Number.isInteger(bucketCount) || (bucketCount as number) <= 0 || (bucketCount as number) > MAX_USAGE_BUCKETS) {
|
|
158
|
+
throw new Error(`bucketCount must be a positive integer up to ${MAX_USAGE_BUCKETS}`);
|
|
159
|
+
}
|
|
160
|
+
const requestedScopeLimit = input["scopeLimit"];
|
|
161
|
+
const scopeLimit = Number.isFinite(requestedScopeLimit) ? Math.max(1, Math.min(USAGE_MAX_DISTINCT_SCOPES, Math.floor(requestedScopeLimit as number))) : USAGE_MAX_DISTINCT_SCOPES;
|
|
162
|
+
const scopes = this.metrics.distinctScopes({ source, since: since as number, until: until as number, limit: scopeLimit });
|
|
163
|
+
// More distinct scopes may exist beyond this bounded list -- that is the only remaining
|
|
164
|
+
// truncation risk once aggregation replaces a per-scope raw-row fetch (see aggregateUsage's
|
|
165
|
+
// own doc comment for the incident this was built to stop repeating).
|
|
166
|
+
const truncated = scopes.length >= scopeLimit;
|
|
167
|
+
const rows = scopes.length === 0 ? [] : this.metrics.aggregateUsage({
|
|
168
|
+
source, scopes, since: since as number, until: until as number, bucketSizeMs, bucketCount: bucketCount as number,
|
|
169
|
+
});
|
|
170
|
+
return { rows, truncated };
|
|
171
|
+
}
|
|
172
|
+
case "metrics.cost_by_task": {
|
|
173
|
+
const since = input["since"];
|
|
174
|
+
const until = input["until"];
|
|
175
|
+
if (!Number.isSafeInteger(since) || !Number.isSafeInteger(until) || (since as number) < 0 || (until as number) < (since as number)) {
|
|
176
|
+
throw new Error("cost by task requires non-negative ordered integer bounds");
|
|
177
|
+
}
|
|
178
|
+
const rows = this.metrics.query({ source: "pi", since: since as number, until: until as number, order: "desc", limit: TASK_COST_QUERY_LIMIT });
|
|
179
|
+
return buildTaskCostSummary(rows, { since: since as number, until: until as number, truncated: rows.length >= TASK_COST_QUERY_LIMIT });
|
|
180
|
+
}
|
|
122
181
|
case "metrics.prune": {
|
|
123
182
|
const before = input["before"];
|
|
124
183
|
if (typeof before !== "number") throw new Error("before is required");
|
|
184
|
+
const force = input["force"] === true;
|
|
185
|
+
const minCutoff = Date.now() - PRUNE_MIN_AGE_MS;
|
|
186
|
+
if (!force && before > minCutoff) {
|
|
187
|
+
throw new Error(`refusing to prune metrics newer than ${new Date(minCutoff).toISOString()} without force: true (this looked like it could delete recent or live data)`);
|
|
188
|
+
}
|
|
125
189
|
return { deleted: this.metrics.pruneBefore(before) };
|
|
126
190
|
}
|
|
127
191
|
case "benchmark.refresh": return this.benchmarks.refresh(input["force"] === true);
|
|
@@ -181,19 +245,16 @@ export interface JittorAppOptions {
|
|
|
181
245
|
maxBodyBytes?: number;
|
|
182
246
|
}
|
|
183
247
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
248
|
+
/**
|
|
249
|
+
* Bearer-check and the trivial health/ready/not-found responses now delegate to
|
|
250
|
+
* `@danypops/daemon-kit/http` (the same handful of lines every daemon's service.ts hand-rolled).
|
|
251
|
+
* The response-size guard below stays jittor-specific: daemon-kit's `jsonResponse` is intentionally
|
|
252
|
+
* unbounded (it has no operation dispatch of its own to guard), while jittor's `/api/v1/ops` can
|
|
253
|
+
* return arbitrarily large query results that must be capped (see SERVICE_MAX_RESPONSE_BYTES).
|
|
254
|
+
*/
|
|
188
255
|
function json(value: unknown, status = 200): Response {
|
|
189
256
|
const body = JSON.stringify(value);
|
|
190
|
-
if (new TextEncoder().encode(body).byteLength > SERVICE_MAX_RESPONSE_BYTES)
|
|
191
|
-
const error = JSON.stringify({ error: "response too large" });
|
|
192
|
-
return new Response(error, {
|
|
193
|
-
status: 413,
|
|
194
|
-
headers: { "content-type": "application/json", "content-length": String(new TextEncoder().encode(error).byteLength) },
|
|
195
|
-
});
|
|
196
|
-
}
|
|
257
|
+
if (new TextEncoder().encode(body).byteLength > SERVICE_MAX_RESPONSE_BYTES) return errorResponse("response too large", 413);
|
|
197
258
|
return new Response(body, {
|
|
198
259
|
status,
|
|
199
260
|
headers: { "content-type": "application/json", "content-length": String(new TextEncoder().encode(body).byteLength) },
|
|
@@ -204,19 +265,16 @@ export function createApp(options: JittorAppOptions): { fetch(request: Request):
|
|
|
204
265
|
const maxBodyBytes = options.maxBodyBytes ?? SERVICE_MAX_BODY_BYTES;
|
|
205
266
|
return {
|
|
206
267
|
async fetch(request: Request): Promise<Response> {
|
|
207
|
-
if (!
|
|
268
|
+
if (!requireBearerToken(request, options.token)) return errorResponse("unauthorized", 401);
|
|
208
269
|
const url = new URL(request.url);
|
|
209
|
-
if (request.method === "GET" && url.pathname === "/health") return
|
|
210
|
-
if (request.method === "GET" && url.pathname === "/ready")
|
|
211
|
-
const ready = options.service.ready();
|
|
212
|
-
return json({ ready }, ready ? 200 : 503);
|
|
213
|
-
}
|
|
270
|
+
if (request.method === "GET" && url.pathname === "/health") return healthResponse(VERSION);
|
|
271
|
+
if (request.method === "GET" && url.pathname === "/ready") return readyResponse(options.service.ready());
|
|
214
272
|
if (request.method === "GET" && url.pathname === "/api/v1/ops") return json({ operations: options.service.operationNames() });
|
|
215
|
-
if (request.method !== "POST" || url.pathname !== "/api/v1/ops") return
|
|
273
|
+
if (request.method !== "POST" || url.pathname !== "/api/v1/ops") return errorResponse("not found", 404);
|
|
216
274
|
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
|
217
|
-
if (contentLength > maxBodyBytes) return
|
|
275
|
+
if (contentLength > maxBodyBytes) return errorResponse("payload too large", 413);
|
|
218
276
|
const text = await request.text();
|
|
219
|
-
if (new TextEncoder().encode(text).byteLength > maxBodyBytes) return
|
|
277
|
+
if (new TextEncoder().encode(text).byteLength > maxBodyBytes) return errorResponse("payload too large", 413);
|
|
220
278
|
try {
|
|
221
279
|
const body = JSON.parse(text) as { op?: unknown; input?: unknown };
|
|
222
280
|
if (typeof body.op !== "string") throw new Error("op is required");
|
package/src/state.ts
CHANGED
|
@@ -1,81 +1,55 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Jittor's XDG paths/token/handle layout, now delegating to `@danypops/daemon-kit/paths` --
|
|
3
|
+
* the shared substrate factored out after jittor's own state.ts and web-spider-daemon's were
|
|
4
|
+
* found byte-identical (see daemon-kit's README). Kept as a thin jittor-named wrapper (same
|
|
5
|
+
* exported function names/signatures as before) so every existing call site (daemon.ts,
|
|
6
|
+
* client.ts, cli.ts, and their tests) is untouched by this migration.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
ensureAuthToken as ensureDaemonKitAuthToken,
|
|
10
|
+
readDaemonHandle as readDaemonKitHandle,
|
|
11
|
+
removeDaemonHandle as removeDaemonKitHandle,
|
|
12
|
+
resolveDaemonPaths,
|
|
13
|
+
writeDaemonHandle as writeDaemonKitHandle,
|
|
14
|
+
type DaemonHandle,
|
|
15
|
+
type DaemonPaths,
|
|
16
|
+
type PathEnvironment,
|
|
17
|
+
} from "@danypops/daemon-kit/paths";
|
|
5
18
|
import {
|
|
6
19
|
DATABASE_FILENAME,
|
|
7
20
|
HANDLE_FILENAME,
|
|
8
21
|
JITTOR_STATE_DIRECTORY,
|
|
9
|
-
LOOPBACK_HOST,
|
|
10
22
|
SYSTEMD_UNIT_NAME,
|
|
11
23
|
TOKEN_FILENAME,
|
|
12
24
|
} from "./constants.ts";
|
|
13
25
|
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
token: string;
|
|
17
|
-
handle: string;
|
|
18
|
-
systemdUnit: string;
|
|
19
|
-
}
|
|
26
|
+
export type JittorPaths = DaemonPaths;
|
|
27
|
+
export type { DaemonHandle, PathEnvironment };
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
env?: Record<string, string | undefined>;
|
|
29
|
-
home?: string;
|
|
30
|
-
uid?: number;
|
|
31
|
-
}
|
|
29
|
+
const JITTOR_PATH_NAMES = {
|
|
30
|
+
stateDirectoryName: JITTOR_STATE_DIRECTORY,
|
|
31
|
+
databaseFilename: DATABASE_FILENAME,
|
|
32
|
+
tokenFilename: TOKEN_FILENAME,
|
|
33
|
+
handleFilename: HANDLE_FILENAME,
|
|
34
|
+
systemdUnitName: SYSTEMD_UNIT_NAME,
|
|
35
|
+
};
|
|
32
36
|
|
|
33
37
|
export function resolveJittorPaths(options: PathEnvironment = {}): JittorPaths {
|
|
34
|
-
|
|
35
|
-
const home = options.home ?? homedir();
|
|
36
|
-
const uid = options.uid ?? process.getuid?.() ?? 0;
|
|
37
|
-
const dataHome = env["XDG_DATA_HOME"] ?? join(home, ".local", "share");
|
|
38
|
-
const stateHome = env["XDG_STATE_HOME"] ?? join(home, ".local", "state");
|
|
39
|
-
const runtimeHome = env["XDG_RUNTIME_DIR"] ?? join("/run", "user", String(uid));
|
|
40
|
-
const configHome = env["XDG_CONFIG_HOME"] ?? join(home, ".config");
|
|
41
|
-
return {
|
|
42
|
-
database: join(dataHome, JITTOR_STATE_DIRECTORY, DATABASE_FILENAME),
|
|
43
|
-
token: join(stateHome, JITTOR_STATE_DIRECTORY, TOKEN_FILENAME),
|
|
44
|
-
handle: join(runtimeHome, JITTOR_STATE_DIRECTORY, HANDLE_FILENAME),
|
|
45
|
-
systemdUnit: join(configHome, "systemd", "user", SYSTEMD_UNIT_NAME),
|
|
46
|
-
};
|
|
38
|
+
return resolveDaemonPaths(JITTOR_PATH_NAMES, options);
|
|
47
39
|
}
|
|
48
40
|
|
|
49
41
|
export function ensureAuthToken(paths: JittorPaths = resolveJittorPaths()): string {
|
|
50
|
-
|
|
51
|
-
if (existsSync(paths.token)) {
|
|
52
|
-
chmodSync(paths.token, 0o600);
|
|
53
|
-
const token = readFileSync(paths.token, "utf8").trim();
|
|
54
|
-
if (!/^[a-f0-9]{64}$/.test(token)) throw new Error("invalid Jittor authentication token");
|
|
55
|
-
return token;
|
|
56
|
-
}
|
|
57
|
-
const token = randomBytes(32).toString("hex");
|
|
58
|
-
writeFileSync(paths.token, `${token}\n`, { mode: 0o600 });
|
|
59
|
-
return token;
|
|
42
|
+
return ensureDaemonKitAuthToken(paths.token, "Jittor");
|
|
60
43
|
}
|
|
61
44
|
|
|
62
45
|
export function writeDaemonHandle(paths: JittorPaths, handle: DaemonHandle): void {
|
|
63
|
-
|
|
64
|
-
const temporary = `${paths.handle}.${process.pid}.tmp`;
|
|
65
|
-
writeFileSync(temporary, `${JSON.stringify(handle)}\n`, { mode: 0o600 });
|
|
66
|
-
renameSync(temporary, paths.handle);
|
|
46
|
+
writeDaemonKitHandle(paths.handle, handle);
|
|
67
47
|
}
|
|
68
48
|
|
|
69
49
|
export function readDaemonHandle(paths: JittorPaths = resolveJittorPaths()): DaemonHandle | null {
|
|
70
|
-
|
|
71
|
-
const value = JSON.parse(readFileSync(paths.handle, "utf8")) as Partial<DaemonHandle>;
|
|
72
|
-
if (value.host !== LOOPBACK_HOST || !Number.isInteger(value.port) || value.port! < 1 || value.port! > 65_535 || !Number.isInteger(value.pid)) return null;
|
|
73
|
-
return value as DaemonHandle;
|
|
74
|
-
} catch {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
50
|
+
return readDaemonKitHandle(paths.handle);
|
|
77
51
|
}
|
|
78
52
|
|
|
79
53
|
export function removeDaemonHandle(paths: JittorPaths = resolveJittorPaths()): void {
|
|
80
|
-
|
|
54
|
+
removeDaemonKitHandle(paths.handle);
|
|
81
55
|
}
|
package/src/version.ts
CHANGED
|
@@ -1,16 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
function packageVersion(): string {
|
|
4
|
-
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as unknown;
|
|
5
|
-
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
|
|
6
|
-
throw new Error("Jittor package manifest must be an object");
|
|
7
|
-
}
|
|
8
|
-
const version = (manifest as Record<string, unknown>)["version"];
|
|
9
|
-
if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
|
|
10
|
-
throw new Error("Jittor package manifest has an invalid version");
|
|
11
|
-
}
|
|
12
|
-
return version;
|
|
13
|
-
}
|
|
1
|
+
import { readPackageVersion } from "@danypops/daemon-kit/version";
|
|
14
2
|
|
|
15
3
|
/** Runtime package version; package.json is the single release source of truth. */
|
|
16
|
-
export const VERSION =
|
|
4
|
+
export const VERSION = readPackageVersion(new URL("../package.json", import.meta.url), "Jittor");
|