@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.
@@ -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: "google-vertex",
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 { COMPACTION_DURATION_ESTIMATE_MAX_SAMPLES, CONTEXT_ASSESSMENT_DEFAULT_WINDOW_MS, CONTEXT_ASSESSMENT_QUERY_LIMIT, SERVICE_MAX_BODY_BYTES, SERVICE_MAX_RESPONSE_BYTES } from "./constants.ts";
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.prune": { before: number };
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
- function authorized(request: Request, token: string): boolean {
185
- return request.headers.get("authorization") === `Bearer ${token}`;
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 (!authorized(request, options.token)) return json({ error: "unauthorized" }, 401);
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 json({ ok: true, version: VERSION });
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 json({ error: "not found" }, 404);
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 json({ error: "payload too large" }, 413);
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 json({ error: "payload too large" }, 413);
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
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
4
- import { randomBytes } from "node:crypto";
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 interface JittorPaths {
15
- database: string;
16
- token: string;
17
- handle: string;
18
- systemdUnit: string;
19
- }
26
+ export type JittorPaths = DaemonPaths;
27
+ export type { DaemonHandle, PathEnvironment };
20
28
 
21
- export interface DaemonHandle {
22
- host: typeof LOOPBACK_HOST;
23
- port: number;
24
- pid: number;
25
- }
26
-
27
- export interface PathEnvironment {
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
- const env = options.env ?? process.env;
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
- mkdirSync(dirname(paths.token), { recursive: true, mode: 0o700 });
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
- mkdirSync(dirname(paths.handle), { recursive: true, mode: 0o700 });
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
- try {
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
- rmSync(paths.handle, { force: true });
54
+ removeDaemonKitHandle(paths.handle);
81
55
  }
package/src/version.ts CHANGED
@@ -1,16 +1,4 @@
1
- import { readFileSync } from "node:fs";
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 = packageVersion();
4
+ export const VERSION = readPackageVersion(new URL("../package.json", import.meta.url), "Jittor");