@edgehero/pi-dispatch 0.1.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.
Files changed (69) hide show
  1. package/.env.example +160 -0
  2. package/deploy/com.pi-dispatch.worker.plist +66 -0
  3. package/deploy/nssm-install.cmd +59 -0
  4. package/deploy/receiver.service +36 -0
  5. package/deploy/worker-env-wrapper.cmd +50 -0
  6. package/deploy/worker-env-wrapper.sh +63 -0
  7. package/deploy/worker.service +55 -0
  8. package/package.json +83 -0
  9. package/src/azure-auth.mjs +61 -0
  10. package/src/azure-host.mjs +236 -0
  11. package/src/azure-identity.mjs +63 -0
  12. package/src/azure-prompt.mjs +118 -0
  13. package/src/branch.mjs +80 -0
  14. package/src/budget.mjs +179 -0
  15. package/src/cli.mjs +208 -0
  16. package/src/config.mjs +329 -0
  17. package/src/connection.mjs +40 -0
  18. package/src/cron.mjs +94 -0
  19. package/src/docker-run.mjs +119 -0
  20. package/src/doctor.mjs +1127 -0
  21. package/src/env-allowlist.mjs +198 -0
  22. package/src/env-file.mjs +153 -0
  23. package/src/exit-code.mjs +32 -0
  24. package/src/flow-gate.mjs +82 -0
  25. package/src/forgejo-auth.mjs +77 -0
  26. package/src/forgejo-host.mjs +172 -0
  27. package/src/forgejo-identity.mjs +74 -0
  28. package/src/forgejo-prompt.mjs +123 -0
  29. package/src/forges.mjs +148 -0
  30. package/src/get-token.mjs +226 -0
  31. package/src/git-dirty.mjs +16 -0
  32. package/src/github-app-setup.mjs +517 -0
  33. package/src/github-host.mjs +159 -0
  34. package/src/github-prompt.mjs +286 -0
  35. package/src/gitlab-auth.mjs +72 -0
  36. package/src/gitlab-host.mjs +200 -0
  37. package/src/gitlab-identity.mjs +61 -0
  38. package/src/gitlab-prompt.mjs +123 -0
  39. package/src/identity.mjs +57 -0
  40. package/src/image-preflight.mjs +180 -0
  41. package/src/import-pi.mjs +451 -0
  42. package/src/index.mjs +177 -0
  43. package/src/init.mjs +77 -0
  44. package/src/job-id.mjs +100 -0
  45. package/src/materialize.mjs +138 -0
  46. package/src/outbox.mjs +179 -0
  47. package/src/packages.mjs +188 -0
  48. package/src/pause-windows.mjs +218 -0
  49. package/src/prepare-github.mjs +260 -0
  50. package/src/prepare-local.mjs +76 -0
  51. package/src/prepare.mjs +199 -0
  52. package/src/pricing.mjs +168 -0
  53. package/src/processor.mjs +360 -0
  54. package/src/queue.mjs +152 -0
  55. package/src/run-container.mjs +133 -0
  56. package/src/run-history.mjs +534 -0
  57. package/src/runtime-settings.mjs +188 -0
  58. package/src/sandbox-cli.mjs +156 -0
  59. package/src/sandbox-store.mjs +269 -0
  60. package/src/sandbox.mjs +171 -0
  61. package/src/scheduler-stall-guard.mjs +67 -0
  62. package/src/schedules.mjs +62 -0
  63. package/src/service.mjs +677 -0
  64. package/src/session-key.mjs +108 -0
  65. package/src/session-store.mjs +249 -0
  66. package/src/start.mjs +502 -0
  67. package/src/subscriptions.mjs +208 -0
  68. package/src/triggers.mjs +491 -0
  69. package/src/up.mjs +315 -0
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Operator-declared subscription plans (issue #53): the one place a flat-rate plan's real price can be
3
+ * stated. Subscription-backed providers ship all-zero rate tables (pi-ai's kimi-coding and zai-coding-cn
4
+ * both do), so their runs record cost 0 and read as FREE when they are PREPAID -- and the env boundary
5
+ * REFUSES OAuth/subscription logins on purpose (env-allowlist.mjs: an expiring token cannot power an
6
+ * unattended service), so no credential ever reaches the worker that could name the plan. An operator-side
7
+ * declaration is therefore the only honest price source, and `subscriptions.json` is that declaration.
8
+ *
9
+ * This file feeds COUNTERFACTUAL ARITHMETIC ONLY (DES-SUBSCRIPTIONS-ARE-COUNTERFACTUAL-ONLY): the admin
10
+ * extension prices runs that already happened against the plans declared here. It never touches job
11
+ * execution, routing, or auth -- the worker exports this validator and reads nothing at job time. The
12
+ * parser lives in the worker purely for the shared-validator anti-drift idiom: the admin imports it the
13
+ * same way it imports parseTriggers/parsePauseWindows, so the two sides cannot drift on the schema.
14
+ *
15
+ * Two rules carry the file's whole posture:
16
+ * - `version` is REQUIRED and a version this build does not understand fails LOUD, naming both
17
+ * versions. Fail-loud-on-newer is the one thing that cannot be retrofitted: a v1 reader that
18
+ * shrugged at v2 would misprice silently forever.
19
+ * - `null` is FIRST-CLASS for a window's `unit`/`limit`: vendors mostly do not disclose them, and a
20
+ * declared "undisclosed" must stay distinguishable from a number someone invented.
21
+ *
22
+ * Pure and fs-free (mirrors triggers.mjs): takes the file TEXT, returns `{ version, subscriptions }`
23
+ * normalized (unknown fields dropped -- the operator-file policy, per triggers.mjs), throws `configError`
24
+ * fail-loud on any invalid known field. `path` is for error messages only.
25
+ *
26
+ * Custom: subscriptions validated inline per triggers.mjs/pause-windows.mjs precedent; zod not in deps
27
+ */
28
+
29
+ import { configError } from "./config.mjs";
30
+
31
+ /** The schema version this build reads and writes. A file declaring a higher one is refused loudly. */
32
+ export const SUBSCRIPTIONS_VERSION = 1;
33
+
34
+ /** The window sizes vendors actually publish limits in; "month" is the calendar billing month. */
35
+ const WINDOW_PERS = new Set(["5h", "7d", "30d", "month"]);
36
+ /** What a window's limit counts. `null` (vendor undisclosed) is deliberately not in this set -- it is
37
+ * handled as its own first-class value, never as a fifth unit. */
38
+ const WINDOW_UNITS = new Set(["tokens", "requests", "prompts", "credits"]);
39
+
40
+ /** A model glob is operator-authored but still bounded: the charset of real model ids plus `*`. */
41
+ const MODEL_GLOB_RE = /^[A-Za-z0-9*._:/-]{1,64}$/;
42
+
43
+ function isNonEmptyString(value) {
44
+ return typeof value === "string" && value.trim() !== "";
45
+ }
46
+
47
+ /** Validate a non-empty array of bounded model globs; returns the trimmed list. */
48
+ function normalizeGlobs(value, label, at, path) {
49
+ if (!Array.isArray(value) || value.length === 0) {
50
+ throw configError(`${at}: ${label} must be a non-empty array of model globs: ${path}`);
51
+ }
52
+ return value.map((glob) => {
53
+ if (typeof glob !== "string" || !MODEL_GLOB_RE.test(glob.trim())) {
54
+ throw configError(`${at}: ${label} glob ${JSON.stringify(glob)} must match ${MODEL_GLOB_RE} (model-id charset plus "*", max 64 chars): ${path}`);
55
+ }
56
+ return glob.trim();
57
+ });
58
+ }
59
+
60
+ /** A boolean-or-absent field; absent takes `fallback`, anything non-boolean is refused. */
61
+ function normalizeBool(value, label, at, path, fallback) {
62
+ if (value === undefined) return fallback;
63
+ if (typeof value !== "boolean") throw configError(`${at}: ${label} must be a boolean: ${path}`);
64
+ return value;
65
+ }
66
+
67
+ /**
68
+ * Parse, validate, and normalize the subscriptions file TEXT. Returns `{ version, subscriptions }` with
69
+ * every subscription and window rebuilt as an explicit literal (defaults applied, unknown fields
70
+ * dropped). Throws `configError` (fail-loud) on any problem, with a positional label naming the offending
71
+ * entry. `path` is for error messages only -- this function touches no filesystem.
72
+ */
73
+ export function parseSubscriptions(text, path) {
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(text);
77
+ } catch (error) {
78
+ throw configError(`subscriptions file is not valid JSON: ${path} (${error.message})`);
79
+ }
80
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
81
+ throw configError(`subscriptions file must be a JSON object: ${path}`);
82
+ }
83
+
84
+ // Version first, before the array shape: a newer file may not even carry a shape this build can read,
85
+ // and the refusal must name both versions rather than degenerate into a field-level error.
86
+ const version = parsed.version;
87
+ if (!Number.isInteger(version) || version < 1) {
88
+ throw configError(`subscriptions file must declare an integer "version" >= 1 (got ${JSON.stringify(version)}): ${path}`);
89
+ }
90
+ if (version > SUBSCRIPTIONS_VERSION) {
91
+ throw configError(`subscriptions file written by a newer pi-dispatch (version ${version}; this build understands ${SUBSCRIPTIONS_VERSION}): ${path}`);
92
+ }
93
+
94
+ const entries = parsed.subscriptions;
95
+ if (!Array.isArray(entries)) {
96
+ throw configError(`subscriptions file must have a "subscriptions" array: ${path}`);
97
+ }
98
+
99
+ const seenIds = new Map();
100
+ return { version, subscriptions: entries.map((entry, index) => normalizeSubscription(entry, index, path, seenIds)) };
101
+ }
102
+
103
+ function normalizeSubscription(entry, index, path, seenIds) {
104
+ const at = `subscription at index ${index}`;
105
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
106
+ throw configError(`${at}: must be an object: ${path}`);
107
+ }
108
+
109
+ if (!isNonEmptyString(entry.id)) throw configError(`${at}: id must be a non-empty string: ${path}`);
110
+ const id = entry.id.trim();
111
+ if (seenIds.has(id)) {
112
+ throw configError(`${at}: id ${JSON.stringify(id)} is already used by the subscription at index ${seenIds.get(id)}: ${path}`);
113
+ }
114
+ seenIds.set(id, index);
115
+
116
+ if (!isNonEmptyString(entry.vendor)) throw configError(`${at}: vendor must be a non-empty string: ${path}`);
117
+ if (!isNonEmptyString(entry.provider)) throw configError(`${at}: provider must be a non-empty string (the pi-ai provider id this plan covers): ${path}`);
118
+
119
+ const models = entry.models === undefined ? ["*"] : normalizeGlobs(entry.models, "models", at, path);
120
+
121
+ const price = entry.price;
122
+ if (price === null || typeof price !== "object" || Array.isArray(price)) {
123
+ throw configError(`${at}: price must be an object { amount, currency, per }: ${path}`);
124
+ }
125
+ if (typeof price.amount !== "number" || !Number.isFinite(price.amount) || price.amount <= 0) {
126
+ throw configError(`${at}: price.amount must be a number > 0: ${path}`);
127
+ }
128
+ if (typeof price.currency !== "string" || !/^[A-Z]{3}$/.test(price.currency)) {
129
+ throw configError(`${at}: price.currency must be a 3-letter uppercase code (e.g. "USD"): ${path}`);
130
+ }
131
+ if (price.per !== "month") {
132
+ throw configError(`${at}: price.per must be "month" (got ${JSON.stringify(price.per)}): ${path}`);
133
+ }
134
+
135
+ const sharedWithOtherProducts = normalizeBool(entry.sharedWithOtherProducts, "sharedWithOtherProducts", at, path, false);
136
+ const hypothetical = normalizeBool(entry.hypothetical, "hypothetical", at, path, false);
137
+
138
+ // Absent and explicit null both mean "no counterfactual declared" -- null is what the normalizer below
139
+ // emits, so the parser must accept its own output (the admin re-validates normalized entries on write).
140
+ let counterfactualModel = null;
141
+ if (entry.counterfactualModel !== undefined && entry.counterfactualModel !== null) {
142
+ const cm = entry.counterfactualModel;
143
+ if (typeof cm !== "object" || Array.isArray(cm) || !isNonEmptyString(cm.provider) || !isNonEmptyString(cm.id)) {
144
+ throw configError(`${at}: counterfactualModel must be { provider, id } with non-empty strings (a PRICED pi-ai model to re-price covered runs at API rates): ${path}`);
145
+ }
146
+ counterfactualModel = { provider: cm.provider.trim(), id: cm.id.trim() };
147
+ }
148
+
149
+ let windows = [];
150
+ if (entry.windows !== undefined) {
151
+ if (!Array.isArray(entry.windows)) throw configError(`${at}: windows must be an array: ${path}`);
152
+ windows = entry.windows.map((w, windowIndex) => normalizeWindow(w, windowIndex, index, path));
153
+ }
154
+
155
+ return {
156
+ id,
157
+ vendor: entry.vendor.trim(),
158
+ provider: entry.provider.trim(),
159
+ models,
160
+ price: { amount: price.amount, currency: price.currency, per: "month" },
161
+ sharedWithOtherProducts,
162
+ hypothetical,
163
+ counterfactualModel,
164
+ windows,
165
+ };
166
+ }
167
+
168
+ function normalizeWindow(w, windowIndex, subIndex, path) {
169
+ const at = `window at index ${windowIndex} of subscription at index ${subIndex}`;
170
+ if (w === null || typeof w !== "object" || Array.isArray(w)) {
171
+ throw configError(`${at}: must be an object: ${path}`);
172
+ }
173
+
174
+ if (!WINDOW_PERS.has(w.per)) {
175
+ throw configError(`${at}: per must be one of 5h|7d|30d|month (got ${JSON.stringify(w.per)}): ${path}`);
176
+ }
177
+ const rolling = normalizeBool(w.rolling, "rolling", at, path, false);
178
+
179
+ // `null` means the vendor did not disclose -- first-class, never coerced into a number or a unit.
180
+ let unit = null;
181
+ if (w.unit !== undefined && w.unit !== null) {
182
+ if (!WINDOW_UNITS.has(w.unit)) {
183
+ throw configError(`${at}: unit must be one of tokens|requests|prompts|credits, or null (vendor undisclosed): ${path}`);
184
+ }
185
+ unit = w.unit;
186
+ }
187
+
188
+ let limit = null;
189
+ if (w.limit !== undefined && w.limit !== null) {
190
+ if (typeof w.limit === "number") {
191
+ if (!Number.isFinite(w.limit) || w.limit <= 0) throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
192
+ limit = w.limit;
193
+ } else if (Array.isArray(w.limit)) {
194
+ const [min, max] = w.limit;
195
+ if (w.limit.length !== 2 || typeof min !== "number" || typeof max !== "number" || !Number.isFinite(min) || !Number.isFinite(max) || min <= 0 || max <= 0 || min >= max) {
196
+ throw configError(`${at}: limit range must be [min, max] with both > 0 and min < max: ${path}`);
197
+ }
198
+ limit = [min, max];
199
+ } else {
200
+ throw configError(`${at}: limit must be a number > 0, [min, max], or null (vendor undisclosed): ${path}`);
201
+ }
202
+ }
203
+
204
+ // Same absent-or-null rule as counterfactualModel: null is the normalizer's own "covers all models".
205
+ const scope = w.scope === undefined || w.scope === null ? null : normalizeGlobs(w.scope, "scope", at, path);
206
+
207
+ return { per: w.per, rolling, unit, limit, scope };
208
+ }