@passioncode-ai/fabric-agent-adapter 0.6.2 → 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.
Files changed (19) hide show
  1. package/CHANGELOG.md +116 -1
  2. package/README.md +28 -5
  3. package/bin/fabric-agent-adapter.js +18 -0
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +9 -3
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +39 -10
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +33 -13
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +77 -14
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -3
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +63 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +106 -3
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +42 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +317 -8
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +53 -1
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +472 -15
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +23 -10
  19. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +14 -3
@@ -0,0 +1,63 @@
1
+ # Usage report — what the agent spent (DEC-0021)
2
+
3
+ Fabric Agent Contract DEC-0021, `docs/specification/service.md` → *Usage report*, schema
4
+ `schemas/service-usage.schema.json`. Optional. A service that calls paid models or tools should
5
+ offer it, so Fabric Dashboards (Spend, MCP `spend`) and Fabric can show each agent's spend beside
6
+ its health without knowing any provider.
7
+
8
+ ## What to record
9
+
10
+ One **receipt per model call**, built from the numbers the provider returned:
11
+
12
+ ```python
13
+ receipt = fs.make_usage_receipt("anthropic", "claude-sonnet-5-5",
14
+ input_tokens=u.input_tokens, output_tokens=u.output_tokens,
15
+ cache_read_tokens=u.cache_read_input_tokens or 0,
16
+ cost_usd=charged, cost_basis="provider") # or "price-list"
17
+ ledger.record(receipt) # fs.JsonlUsageLedger(data / "usage.jsonl")
18
+ ```
19
+
20
+ ```js
21
+ ledger.record(k.makeUsageReceipt('openrouter', model, { inputTokens, outputTokens, costUsd: usage.cost ?? null }));
22
+ ```
23
+
24
+ - **The provider's own numbers.** Take tokens from the response's usage block, not from a local
25
+ estimate. The cost is `provider` when the provider reported the charge (OpenRouter `usage.cost`,
26
+ a billing API) and `price-list` when you computed it from a published price list.
27
+ - **Unknown is `None`/`null`, never `0`.** A local model, or a provider that reports no price,
28
+ records `cost_usd=None`. The report counts it in `unpricedCalls`, and a host shows a lower bound
29
+ («≥ $x») or «unknown». A call that is really free (a template, a cached answer) records
30
+ `cost_usd=0.0` with `price-list`.
31
+ - **No content.** A receipt carries no prompt, output, caller or user id. The kits accept only the
32
+ fields above.
33
+ - If the service already keeps a ledger (a jobs table with token counts), build the receipts from
34
+ it and pass them to `usage_report(...)`. That avoids a second store.
35
+
36
+ ## What to answer
37
+
38
+ Declare the surface in the well-known document and serve the report behind the service token:
39
+
40
+ ```python
41
+ surfaces={..., "usage": {"path": fs.USAGE_PATH}} # "/fabric/v1/usage"
42
+ # GET /fabric/v1/usage, token required:
43
+ return send(200, ledger.report(service_id=ID, instance=INSTANCE, budget={"period": "month", "limitUsd": 100}))
44
+ ```
45
+
46
+ `usage_report` / `usageReport` give the last 31 UTC days, oldest first, per provider and model,
47
+ with day totals that are the sums of the model rows (`FAC-SEM-025`). Days without calls are
48
+ left out.
49
+
50
+ **Budget.** Pass `budget` only if the service **enforces** that limit itself. The report states
51
+ it, and the host only shows it.
52
+
53
+ Call `ledger.prune()` at start (or daily) to keep the file at the reported window (LC-12).
54
+
55
+ `JsonlUsageLedger` appends 0600 JSON lines. A line torn by a killed writer is skipped, and the
56
+ next receipt never glues onto it.
57
+
58
+ ## Verify
59
+
60
+ `check_service.py <id>` reads a declared report: `usage.requires-token` (401 without the token)
61
+ and `usage.report` (identity, USD, dates forward, null for all-unpriced rows, sums). The
62
+ `sample_service.py` serves one. Fabric Dashboards → **Spend** shows it within a minute of opening
63
+ the page.
@@ -324,6 +324,42 @@ class Probe:
324
324
  self.add("events.page", "FAIL" if bad else "PASS",
325
325
  ("bad events: " + ", ".join(bad)) if bad else "%d event(s), cursor %r" % (len(events), page["cursor"]))
326
326
 
327
+ def usage_rules(self) -> None:
328
+ """DEC-0021: a declared usage report requires the token and adds up (FAC-SEM-025)."""
329
+ usage = ((self.wk or {}).get("surfaces") or {}).get("usage")
330
+ if not usage:
331
+ self.add("usage.report", "NOT_RUN", "surfaces.usage not declared (optional; a service that calls paid models should offer it)")
332
+ return
333
+ path = str(usage.get("path", ""))
334
+ if not path.startswith("/") or path.startswith("//"):
335
+ self.add("usage.report", "FAIL", "surfaces.usage.path %r is not a path on the origin" % path)
336
+ return
337
+ try:
338
+ status, _, _ = self.request("GET", path)
339
+ self.add("usage.requires-token", "PASS" if status == 401 else "FAIL", "HTTP %d without a token" % status)
340
+ except OSError as exc:
341
+ self.add("usage.requires-token", "NOT_RUN", str(exc))
342
+ if not self.token:
343
+ self.add("usage.report", "NOT_RUN", "no readable token")
344
+ return
345
+ try:
346
+ status, _, body = self.request("GET", path, self.auth_headers())
347
+ except OSError as exc:
348
+ self.add("usage.report", "NOT_RUN", str(exc))
349
+ return
350
+ if status != 200:
351
+ self.add("usage.report", "FAIL", "HTTP %d with the token" % status)
352
+ return
353
+ try:
354
+ report = json.loads(body)
355
+ except ValueError:
356
+ self.add("usage.report", "FAIL", "not JSON")
357
+ return
358
+ problems = usage_problems(report, str(self.d.get("id")), str(self.d.get("instance", "default")))
359
+ days = report.get("days") if isinstance(report, dict) else None
360
+ self.add("usage.report", "FAIL" if problems else "PASS",
361
+ "; ".join(problems[:5]) if problems else "%d day(s), unknown cost kept null" % len(days or []))
362
+
327
363
  def login_rules(self) -> None:
328
364
  dashboard = ((self.wk or {}).get("surfaces") or {}).get("dashboard")
329
365
  if not dashboard or not dashboard.get("login"):
@@ -360,7 +396,7 @@ class Probe:
360
396
  body["_meta"] = {"io.modelcontextprotocol/protocolVersion": fi.MCP_REVISION,
361
397
  "io.modelcontextprotocol/clientCapabilities": {}, "traceparent": self.sent_traceparent}
362
398
  headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream",
363
- "MCP-Protocol-Version": fi.MCP_REVISION}
399
+ **fi.mcp_request_headers(method, params)}
364
400
  headers.update(self.auth_headers())
365
401
  message = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": body}).encode()
366
402
  status, resp_headers, raw = self.request("POST", path, headers, message)
@@ -412,9 +448,24 @@ class Probe:
412
448
  extra = sorted(set(listed) - names)
413
449
  self.add("interop.well-known-capabilities", "FAIL" if extra else "PASS",
414
450
  ("listed but not in the manifest: " + ", ".join(extra)) if extra else "%d listed, all in the manifest" % len(listed))
415
- if not mcp_surface or not self.token:
451
+ own = bool(mcp_surface) and mcp_surface.get("auth") == "own"
452
+ if own and self.token:
453
+ # DEC-0024: the MCP surface has credentials of its own and must refuse the host's token.
454
+ try:
455
+ self.mcp_call("tools/list", {})
456
+ self.add("interop.mcp-own-auth", "FAIL", "surfaces.mcp.auth is own, yet the MCP surface accepted the descriptor's token")
457
+ except OSError as exc:
458
+ refused = any(code in str(exc) for code in ("HTTP 401", "HTTP 403"))
459
+ self.add("interop.mcp-own-auth", "PASS" if refused else "NOT_RUN",
460
+ ("the MCP surface refuses the host's token (%s), as auth: own says" % exc) if refused else str(exc))
461
+ except ValueError as exc:
462
+ self.add("interop.mcp-own-auth", "NOT_RUN", str(exc))
463
+ if not mcp_surface or not self.token or own:
464
+ reason = ("no MCP surface" if not mcp_surface
465
+ else "surfaces.mcp.auth is own: the MCP surface takes its callers' credentials, not the host's token (DEC-0024)" if own
466
+ else "no readable token")
416
467
  for rule in ("interop.output-schema-object", "interop.tools-match", "interop.job-tools", "interop.unknown-job", "interop.trace-propagation"):
417
- self.add(rule, "NOT_RUN", "no MCP surface" if not mcp_surface else "no readable token")
468
+ self.add(rule, "NOT_RUN", reason)
418
469
  else:
419
470
  try:
420
471
  listing = self.mcp_call("tools/list", {})
@@ -621,12 +672,64 @@ class Probe:
621
672
  if self.wk is not None:
622
673
  self.network_rules()
623
674
  self.auth_rules()
675
+ self.usage_rules()
624
676
  self.login_rules()
625
677
  self.interop_rules()
626
678
  self.lifecycle_rules()
627
679
  return self.results
628
680
 
629
681
 
682
+ def usage_problems(report: Any, service_id: str, instance: str) -> List[str]:
683
+ """What makes this not a DEC-0021 usage report of the service: shape, identity and the sums of
684
+ FAC-SEM-025. An unknown cost must be null; a day's totals are the sums of its models."""
685
+ if not isinstance(report, dict):
686
+ return ["not an object"]
687
+ out: List[str] = []
688
+ if report.get("protocol") != fs.PROTOCOL:
689
+ out.append("protocol is %r" % report.get("protocol"))
690
+ if report.get("service") != {"id": service_id, "instance": instance}:
691
+ out.append("reports for %r, not %s.%s" % (report.get("service"), service_id, instance))
692
+ if report.get("currency") != "USD":
693
+ out.append("currency is not USD")
694
+ days = report.get("days")
695
+ if not isinstance(days, list) or len(days) > fs.USAGE_DAYS:
696
+ return out + ["days is not a list of at most 31"]
697
+ fields = ("calls", "unpricedCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens")
698
+
699
+ def priced(row: Dict[str, Any], at: str) -> None:
700
+ calls, unpriced, cost = row.get("calls", 0), row.get("unpricedCalls", 0), row.get("costUsd")
701
+ if unpriced > calls:
702
+ out.append("%s: more unpriced calls than calls" % at)
703
+ if calls and unpriced == calls and cost is not None:
704
+ out.append("%s: every call is unpriced, so the cost must be null, not %r" % (at, cost))
705
+ if unpriced < calls and cost is None:
706
+ out.append("%s: priced calls carry a cost" % at)
707
+ if cost is not None and (not isinstance(cost, (int, float)) or cost < 0):
708
+ out.append("%s: costUsd is not a non-negative number" % at)
709
+
710
+ previous = ""
711
+ for i, day in enumerate(days):
712
+ at = "days[%d]" % i
713
+ date = str(day.get("date", "")) if isinstance(day, dict) else ""
714
+ if not re.match(r"^\d{4}-\d{2}-\d{2}$", date) or date <= previous:
715
+ out.append("%s: dates must run forward" % at)
716
+ previous = date
717
+ priced(day, at)
718
+ models = day.get("byModel") if isinstance(day, dict) else None
719
+ if not isinstance(models, list):
720
+ out.append("%s: byModel missing" % at)
721
+ continue
722
+ for j, m in enumerate(models):
723
+ priced(m, "%s.byModel[%d]" % (at, j))
724
+ for f in fields:
725
+ if day.get(f, 0) != sum(m.get(f, 0) for m in models):
726
+ out.append("%s: %s is not the sum of its models" % (at, f))
727
+ known = sum(m["costUsd"] for m in models if isinstance(m.get("costUsd"), (int, float)))
728
+ if isinstance(day.get("costUsd"), (int, float)) and abs(day["costUsd"] - known) > 1e-6:
729
+ out.append("%s: costUsd is not the sum of its models' known costs" % at)
730
+ return out
731
+
732
+
630
733
  def same_repository(a: str, b: str) -> bool:
631
734
  """git@github.com:o/r.git, https://github.com/o/r and github.com/o/r are one repository."""
632
735
  def norm(u: str) -> str:
@@ -220,3 +220,45 @@ export class JobStore {
220
220
  cancel(jobId) { return this.transition(jobId, 'cancelled'); }
221
221
  }
222
222
  // #endregion interop-kit-node
223
+
224
+ // --- MCP 2026-07-28 standard headers ------------------------------------------------------
225
+
226
+ export const NAMED_METHODS = { 'tools/call': 'name', 'prompts/get': 'name', 'resources/read': 'uri' };
227
+ export const HEADER_MISMATCH = -32020; // the code the official 2026-07-28 server SDK answers a header/body mismatch with
228
+
229
+ /**
230
+ * MCP 2026-07-28 Streamable HTTP mirrors the body in `Mcp-Method` (every request and
231
+ * notification) and `Mcp-Name` (`params.name` or `params.uri` of tools/call, prompts/get,
232
+ * resources/read). A server MUST reject a mismatch; a request that declares 2026-07-28 must carry
233
+ * them. Returns the problem as a sentence, or null. `headers` is a plain object (any case) or a
234
+ * Headers instance.
235
+ */
236
+ export function mcpHeaderProblem(message, headers) {
237
+ if (!message || typeof message !== 'object' || !headers) return null;
238
+ const header = (name) => {
239
+ if (typeof headers.get === 'function') return headers.get(name) ?? null;
240
+ const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase());
241
+ const value = key === undefined ? undefined : headers[key];
242
+ return value === undefined ? null : String(Array.isArray(value) ? value[0] : value);
243
+ };
244
+ const declared = header('MCP-Protocol-Version') === MCP_REVISION;
245
+ const { method } = message;
246
+ const sent = header('Mcp-Method');
247
+ if (sent === null && declared) return `the body names method ${method} but the required Mcp-Method header is absent`;
248
+ if (sent !== null && sent !== method) return `Mcp-Method ${sent} disagrees with the body's method ${method}`;
249
+ const field = NAMED_METHODS[method];
250
+ if (field) {
251
+ const named = header('Mcp-Name');
252
+ if (named === null && declared) return `the body carries params.${field} but the required Mcp-Name header is absent`;
253
+ if (named !== null && named !== String(message.params?.[field])) return `Mcp-Name disagrees with the body's params.${field}`;
254
+ }
255
+ return null;
256
+ }
257
+
258
+ /** The standard headers a 2026-07-28 client sends with one request. */
259
+ export function mcpRequestHeaders(method, params = {}) {
260
+ const out = { 'MCP-Protocol-Version': MCP_REVISION, 'Mcp-Method': method };
261
+ const field = NAMED_METHODS[method];
262
+ if (field && params[field] !== undefined && params[field] !== null) out['Mcp-Name'] = String(params[field]);
263
+ return out;
264
+ }
@@ -1,7 +1,7 @@
1
1
  // Reference kit for the fabric-service/0.1 local service extension — Node.js 20+, no dependencies.
2
2
  // The Node twin of fabric_service.py: same rules, same file formats, interoperable locks.
3
3
  // Normative source: fabric-agent-contract docs/specification/service.md (DEC-0015; the remote
4
- // placement — an online agent or dashboard at an https origin — DEC-0019).
4
+ // placement — an online agent or dashboard at an https origin — DEC-0019; the usage report DEC-0021).
5
5
 
6
6
  import crypto from 'node:crypto';
7
7
  import fs from 'node:fs';
@@ -12,6 +12,19 @@ import path from 'node:path';
12
12
  export const PROTOCOL = 'fabric-service/0.1';
13
13
  export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/service/0.1';
14
14
  export const EXIT_ALREADY_RUNNING = 75;
15
+ // Lifecycle contract (fabric-workspace knowledge/lifecycle.md), the same values as fabric_service.py.
16
+ // A launchd-supervised copy that finds the lock held backs off in-process before its exit 75 (LC-03).
17
+ export const SUPERVISOR_ENV = 'FABRIC_SERVICE_SUPERVISOR';
18
+ export const LOCK_WAIT_SUPERVISED_SECONDS = 300;
19
+ export const LOCK_BACKOFF_FIRST_SECONDS = 0.5;
20
+ export const LOCK_BACKOFF_MAX_SECONDS = 30;
21
+ // LC-01: SIGTERM reaches exit in at most 10 s with work in flight — drain 8 s, hard exit 2 s later.
22
+ export const DRAIN_SECONDS = 8;
23
+ export const DRAIN_GRACE_SECONDS = 2;
24
+ export const EXIT_HARD_STOP = 70;
25
+ // LC-12: logs rotate by size, 5 x 5 MB by default.
26
+ export const LOG_MAX_BYTES = 5 * 1024 * 1024;
27
+ export const LOG_BACKUPS = 5;
15
28
  export const LEVELS = ['info', 'notice', 'warning', 'error'];
16
29
  export const STATUSES = ['starting', 'ready', 'degraded', 'stopping'];
17
30
  export const EVENTS_DEFAULT_LIMIT = 50;
@@ -151,13 +164,42 @@ function readPid(file) {
151
164
  }
152
165
  }
153
166
 
154
- export async function holdSingleInstance(dataDir) {
155
- try {
156
- return await new InstanceLock(dataDir).acquire();
157
- } catch (error) {
158
- if (!(error instanceof AlreadyRunning)) throw error;
159
- process.stderr.write(`${error.message}\n`);
160
- process.exit(EXIT_ALREADY_RUNNING);
167
+ /** True when launchd started this process from a plist that says so (FABRIC_SERVICE_SUPERVISOR=launchd). */
168
+ export const supervisedByLaunchd = () => process.env[SUPERVISOR_ENV] === 'launchd';
169
+
170
+ const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));
171
+
172
+ /**
173
+ * Take the lock or exit 75 with one sentence naming the holder. Started by hand, a held lock exits
174
+ * 75 at once. Under launchd (KeepAlive) an immediate exit is a respawn every ThrottleInterval,
175
+ * forever; so a supervised copy first backs off in-process (0.5 s doubling to 30 s,
176
+ * LOCK_WAIT_SUPERVISED_SECONDS in all), takes over if the holder leaves, and exits 75 only when
177
+ * the wait runs out. `waitSeconds` overrides the choice (0 = never wait).
178
+ */
179
+ export async function holdSingleInstance(dataDir, {
180
+ waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code),
181
+ } = {}) {
182
+ const wait = waitSeconds ?? (supervisedByLaunchd() ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
183
+ const deadline = clock() + Math.max(0, wait);
184
+ let delay = LOCK_BACKOFF_FIRST_SECONDS;
185
+ let announced = false;
186
+ for (;;) {
187
+ try {
188
+ return await new InstanceLock(dataDir).acquire();
189
+ } catch (error) {
190
+ if (!(error instanceof AlreadyRunning)) throw error;
191
+ const remaining = deadline - clock();
192
+ if (remaining <= 0) {
193
+ process.stderr.write(`${error.message}\n`);
194
+ return exit(EXIT_ALREADY_RUNNING);
195
+ }
196
+ if (!announced) {
197
+ process.stderr.write(`${error.message} Waiting up to ${Math.round(remaining)} s for it to exit.\n`);
198
+ announced = true;
199
+ }
200
+ await sleep(Math.min(delay, remaining));
201
+ delay = Math.min(delay * 2, LOCK_BACKOFF_MAX_SECONDS);
202
+ }
161
203
  }
162
204
  }
163
205
 
@@ -346,6 +388,273 @@ export async function eventsPage(fetch, after, limit) {
346
388
  return { events, cursor: events.length ? events.at(-1).id : (after ?? null) };
347
389
  }
348
390
 
391
+ // --- usage report (DEC-0021) -------------------------------------------------------------
392
+
393
+ export const USAGE_PATH = '/fabric/v1/usage';
394
+ export const USAGE_DAYS = 31;
395
+ export const COST_BASES = ['provider', 'price-list', 'unknown'];
396
+ const PROVIDER = /^[a-z][a-z0-9._-]{0,63}$/;
397
+ const COUNTS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'];
398
+
399
+ /**
400
+ * One model call, from the provider's own usage numbers. No prompt, output or caller.
401
+ * `costUsd` null means it cannot be priced: the report counts it as unpriced, never as $0.
402
+ */
403
+ export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, costUsd = null, costBasis, at } = {}) {
404
+ if (!PROVIDER.test(provider ?? '')) throw new ServiceError('provider must be a lowercase name such as anthropic or openrouter.');
405
+ model = String(model ?? '').trim();
406
+ if (!model || model.length > 128) throw new ServiceError('model must be 1 to 128 characters.');
407
+ const counts = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens };
408
+ for (const [name, value] of Object.entries(counts)) if (!Number.isInteger(value) || value < 0) throw new ServiceError(`${name} must be a non-negative integer.`);
409
+ if (costUsd !== null && (typeof costUsd !== 'number' || !Number.isFinite(costUsd) || costUsd < 0)) throw new ServiceError('costUsd must be a non-negative number or null.');
410
+ const basis = costBasis ?? (costUsd === null ? 'unknown' : 'provider');
411
+ if (!COST_BASES.includes(basis) || (costUsd === null) !== (basis === 'unknown')) throw new ServiceError('costBasis is provider or price-list with a cost, unknown without one.');
412
+ return { at: at ?? nowIso(), provider, model, ...counts, costUsd, costBasis: basis };
413
+ }
414
+
415
+ const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
416
+ const emptyRow = (extra = {}) => ({ ...extra, calls: 0, unpricedCalls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cost: 0 });
417
+ function closeRow(row) {
418
+ const { cost, ...rest } = row;
419
+ return { ...rest, costUsd: rest.calls && rest.unpricedCalls === rest.calls ? null : Math.round(cost * 1e6) / 1e6 };
420
+ }
421
+
422
+ /**
423
+ * The service-usage.schema.json answer: the last 31 UTC days, oldest first, per provider and
424
+ * model; days without calls are omitted. A row whose calls are all unpriced costs null; totals
425
+ * are the sums of the model rows (FAC-SEM-025).
426
+ */
427
+ export function usageReport(receipts, { id, instance = 'default', now = Date.now(), budget } = {}) {
428
+ const first = utcDate(now - (USAGE_DAYS - 1) * 86_400_000), last = utcDate(now);
429
+ const days = new Map();
430
+ for (const r of receipts) {
431
+ const date = String(r.at ?? '').slice(0, 10);
432
+ if (date < first || date > last) continue;
433
+ const rows = days.get(date) ?? new Map();
434
+ days.set(date, rows);
435
+ const k = `${r.provider}\u0000${r.model}`;
436
+ const row = rows.get(k) ?? emptyRow({ provider: r.provider, model: r.model, bases: new Set() });
437
+ rows.set(k, row);
438
+ row.calls += 1;
439
+ for (const c of COUNTS) row[c] += Number(r[c] ?? 0);
440
+ if (r.costUsd === null || r.costUsd === undefined) row.unpricedCalls += 1; else row.cost += r.costUsd;
441
+ row.bases.add(r.costBasis ?? 'unknown');
442
+ }
443
+ const outDays = [...days.keys()].sort().map((date) => {
444
+ const models = [...days.get(date).values()]
445
+ .map(({ bases, ...row }) => closeRow({ ...row, costBasis: bases.size === 1 ? [...bases][0] : 'mixed' }))
446
+ .sort((a, b) => (a.provider + a.model < b.provider + b.model ? -1 : 1));
447
+ const day = emptyRow({ date });
448
+ for (const m of models) {
449
+ for (const c of ['calls', 'unpricedCalls', ...COUNTS]) day[c] += m[c];
450
+ day.cost += m.costUsd ?? 0;
451
+ }
452
+ return { ...closeRow(day), byModel: models };
453
+ });
454
+ const report = { protocol: PROTOCOL, service: { id, instance }, generatedAt: new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z'), currency: 'USD', days: outDays };
455
+ if (budget !== undefined) {
456
+ if (!['day', 'month'].includes(budget?.period) || typeof budget.limitUsd !== 'number' || !(budget.limitUsd > 0)) throw new ServiceError('budget is {period: day|month, limitUsd > 0}.');
457
+ const prefix = budget.period === 'day' ? last : last.slice(0, 7);
458
+ const window = outDays.filter((d) => d.date.startsWith(prefix));
459
+ const unknown = window.length > 0 && window.every((d) => d.costUsd === null);
460
+ report.budget = { period: budget.period, limitUsd: budget.limitUsd, spentUsd: unknown ? null : Math.round(window.reduce((n, d) => n + (d.costUsd ?? 0), 0) * 1e6) / 1e6 };
461
+ }
462
+ return report;
463
+ }
464
+
465
+ /** Usage receipts for services that keep none yet: append-only JSON lines, 0600, pruned to the window (LC-12).
466
+ * One writer process (the service holds its instance lock); every method is synchronous, so calls never interleave. */
467
+ export class JsonlUsageLedger {
468
+ constructor(file) { this.file = file; }
469
+
470
+ record(receipt) {
471
+ ensurePrivateDir(path.dirname(this.file));
472
+ const fd = fs.openSync(this.file, 'a+', 0o600);
473
+ try {
474
+ const { size } = fs.fstatSync(fd);
475
+ const last = Buffer.alloc(1);
476
+ // A killed writer left a torn line: never glue a receipt to it.
477
+ const lead = size && fs.readSync(fd, last, 0, 1, size - 1) === 1 && last[0] !== 0x0a ? '\n' : '';
478
+ fs.writeSync(fd, lead + JSON.stringify(receipt) + '\n');
479
+ } finally {
480
+ fs.closeSync(fd);
481
+ }
482
+ }
483
+
484
+ receipts() {
485
+ if (!fs.existsSync(this.file)) return [];
486
+ const out = [];
487
+ for (const line of fs.readFileSync(this.file, 'utf8').split('\n')) {
488
+ if (!line) continue;
489
+ try { out.push(JSON.parse(line)); } catch { /* a torn last line from a killed writer is skipped */ }
490
+ }
491
+ return out;
492
+ }
493
+
494
+ prune(now = Date.now()) {
495
+ const first = utcDate(now - (USAGE_DAYS - 1) * 86_400_000);
496
+ const all = this.receipts();
497
+ const kept = all.filter((r) => String(r.at ?? '').slice(0, 10) >= first);
498
+ if (kept.length !== all.length) atomicWrite(this.file, kept.map((r) => JSON.stringify(r) + '\n').join(''));
499
+ return all.length - kept.length;
500
+ }
501
+
502
+ report(options) { return usageReport(this.receipts(), options); }
503
+ }
504
+
505
+ // --- logs (LC-12) ------------------------------------------------------------------------
506
+
507
+ /**
508
+ * One structured log: JSON lines rotated by size (5 x 5 MB by default), files 0600 in a 0700
509
+ * directory. `service.jsonl` -> `.1` ... `.<backups>`; the oldest falls off. Never pass a token,
510
+ * a cookie, a login code or a request body that may carry one.
511
+ */
512
+ export class RotatingLog {
513
+ constructor(file, { maxBytes = LOG_MAX_BYTES, backups = LOG_BACKUPS } = {}) {
514
+ if (maxBytes < 256 || backups < 1) throw new ServiceError('A rotating log needs maxBytes >= 256 and at least one backup.');
515
+ this.file = file;
516
+ this.maxBytes = maxBytes;
517
+ this.backups = backups;
518
+ }
519
+
520
+ line(record) {
521
+ let data = Buffer.from(`${JSON.stringify(record)}\n`);
522
+ if (data.length <= this.maxBytes) return data;
523
+ const cut = { ...record, truncated: true };
524
+ let message = String(record.message ?? '');
525
+ while (message) {
526
+ message = message.slice(0, Math.floor(message.length / 2));
527
+ cut.message = message;
528
+ data = Buffer.from(`${JSON.stringify(cut)}\n`);
529
+ if (data.length <= this.maxBytes) return data;
530
+ }
531
+ return Buffer.from(`${JSON.stringify({ at: record.at, level: record.level, message: '', truncated: true })}\n`);
532
+ }
533
+
534
+ write(level, message, fields = {}) {
535
+ const data = this.line({ at: nowIso(), level: String(level), message: String(message), ...fields });
536
+ ensurePrivateDir(path.dirname(this.file));
537
+ let size = 0;
538
+ try { size = fs.statSync(this.file).size; } catch (error) { if (error.code !== 'ENOENT') throw error; }
539
+ if (size && size + data.length > this.maxBytes) this.rotate();
540
+ const fd = fs.openSync(this.file, 'a', 0o600);
541
+ try { fs.writeSync(fd, data); } finally { fs.closeSync(fd); }
542
+ }
543
+
544
+ rotate() {
545
+ for (let i = this.backups - 1; i > 0; i -= 1) {
546
+ if (fs.existsSync(`${this.file}.${i}`)) fs.renameSync(`${this.file}.${i}`, `${this.file}.${i + 1}`);
547
+ }
548
+ fs.renameSync(this.file, `${this.file}.1`);
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Call once at start: a launchd stdout file over `maxBytes` is copied to `<file>.1` and truncated
554
+ * in place (launchd holds it open for append, so a rename would leave it writing to the old name).
555
+ */
556
+ export function capStdoutLog(file, maxBytes = LOG_MAX_BYTES) {
557
+ let size;
558
+ try { size = fs.statSync(file).size; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
559
+ if (size <= maxBytes) return false;
560
+ fs.copyFileSync(file, `${file}.1`);
561
+ fs.chmodSync(`${file}.1`, 0o600);
562
+ fs.truncateSync(file, 0);
563
+ return true;
564
+ }
565
+
566
+ // --- shutdown (LC-01) ----------------------------------------------------------------------
567
+
568
+ export class Stopping extends ServiceError {}
569
+
570
+ /**
571
+ * SIGTERM/SIGINT: stop taking new work, drain in-flight work until `deadline` seconds, then hand
572
+ * over with `onStop(drained)`. A hand-over that never settles is cut by a hard exit
573
+ * (EXIT_HARD_STOP) `grace` seconds later.
574
+ *
575
+ * const drain = new Drain().install((drained) => server.close(() => process.exit(0)),
576
+ * { onStopping: () => log.append('service.stopping', 'info', 'Stopping.') });
577
+ * await drain.work(async () => { ... }); // rejects with Stopping once a stop has begun
578
+ */
579
+ export class Drain {
580
+ constructor({ deadline = DRAIN_SECONDS, grace = DRAIN_GRACE_SECONDS } = {}) {
581
+ this.deadline = deadline;
582
+ this.grace = grace;
583
+ this.inflight = 0;
584
+ this.stopping = false;
585
+ this.waiters = new Set();
586
+ }
587
+
588
+ async work(fn) {
589
+ if (this.stopping) throw new Stopping('The service is stopping and takes no new work.');
590
+ this.inflight += 1;
591
+ try {
592
+ return await fn();
593
+ } finally {
594
+ this.inflight -= 1;
595
+ if (this.inflight === 0) for (const wake of this.waiters) wake();
596
+ }
597
+ }
598
+
599
+ /** Refuse new work from now on; false when a stop had already begun. */
600
+ requestStop() {
601
+ if (this.stopping) return false;
602
+ this.stopping = true;
603
+ return true;
604
+ }
605
+
606
+ /** Resolves true when in-flight work finished inside `timeoutSeconds`, false otherwise. */
607
+ wait(timeoutSeconds) {
608
+ if (this.inflight === 0) return Promise.resolve(true);
609
+ return new Promise((resolve) => {
610
+ const wake = () => { clearTimeout(timer); this.waiters.delete(wake); resolve(true); };
611
+ const timer = setTimeout(() => { this.waiters.delete(wake); resolve(false); }, timeoutSeconds * 1000);
612
+ this.waiters.add(wake);
613
+ });
614
+ }
615
+
616
+ install(onStop, { onStopping, signals = ['SIGTERM', 'SIGINT'] } = {}) {
617
+ const handler = () => {
618
+ if (!this.requestStop()) return; // a second signal changes nothing: the hard exit holds the deadline
619
+ setTimeout(() => process.exit(EXIT_HARD_STOP), (this.deadline + this.grace) * 1000).unref();
620
+ (async () => {
621
+ try { await onStopping?.(); } catch (error) { process.stderr.write(`${error?.stack ?? error}\n`); }
622
+ await onStop(await this.wait(this.deadline));
623
+ })();
624
+ };
625
+ for (const signal of signals) process.on(signal, handler);
626
+ return this;
627
+ }
628
+ }
629
+
630
+ // --- releases (LC-11, LC-15) -----------------------------------------------------------------
631
+
632
+ /**
633
+ * Keep the release the job runs and the one before it; remove older release directories, newest
634
+ * first by modification time. `current` (a release or a symlink to one) is never removed, even
635
+ * after a rollback. Symlinks and files are left alone. Returns the removed paths.
636
+ */
637
+ export function pruneReleases(releasesDir, { keep = 2, current } = {}) {
638
+ if (keep < 2) throw new ServiceError('keep at least the current release and the one before it (keep >= 2).');
639
+ if (!fs.existsSync(releasesDir)) return [];
640
+ const releases = fs.readdirSync(releasesDir, { withFileTypes: true })
641
+ .filter((e) => e.isDirectory() && !e.isSymbolicLink())
642
+ .map((e) => path.join(releasesDir, e.name))
643
+ .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
644
+ const keepers = [];
645
+ if (current) {
646
+ const resolved = fs.realpathSync(current);
647
+ keepers.push(...releases.filter((p) => fs.realpathSync(p) === resolved));
648
+ }
649
+ for (const release of releases) {
650
+ if (keepers.length >= keep) break;
651
+ if (!keepers.includes(release)) keepers.push(release);
652
+ }
653
+ const removed = releases.filter((p) => !keepers.includes(p));
654
+ for (const release of removed) fs.rmSync(release, { recursive: true, force: true });
655
+ return removed;
656
+ }
657
+
349
658
  // --- operator login -------------------------------------------------------------------
350
659
 
351
660
  export class LoginCodes {