@ouro.bot/cli 0.1.0-alpha.955 → 0.1.0-alpha.957
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/changelog.json +24 -0
- package/deploy/unraid/credit-gateway.md +95 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +22 -8
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/gateway/keys.js +117 -0
- package/dist/gateway/server.js +429 -0
- package/dist/heart/auth/auth-flow.js +17 -3
- package/dist/heart/config.js +5 -1
- package/dist/heart/core.js +33 -12
- package/dist/heart/daemon/cli-exec.js +25 -0
- package/dist/heart/daemon/cli-help.js +14 -2
- package/dist/heart/daemon/cli-parse.js +83 -0
- package/dist/heart/external-events/router.js +20 -12
- package/dist/heart/identity.js +1 -1
- package/dist/heart/provider-credentials.js +2 -2
- package/dist/heart/provider-ping.js +2 -1
- package/dist/heart/providers/anthropic.js +65 -7
- package/dist/heart/runtime-credentials.js +2 -1
- package/dist/repertoire/house-sweep.js +10 -8
- package/dist/repertoire/tools-continuity.js +16 -10
- package/dist/senses/private-runtime-worker.js +53 -1
- package/dist/senses/sanctuary-health-acceptance-probe.js +1 -1
- package/dist/senses/sanctuary-health.js +31 -5
- package/dist/senses/telegram-handoff.js +75 -1
- package/dist/senses/telegram.js +35 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.957",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Butler no longer starts a model turn just to close a problem that fixed itself, unless he had told you about it.",
|
|
8
|
+
"Background turns that run out of steps now wrap up cleanly instead of failing.",
|
|
9
|
+
"Turns started by server events now count against Butler's daily allowance, and repeated failures pause them for 30 minutes.",
|
|
10
|
+
"Media lookups now say whether a title was actually requested and whether it is downloading, instead of claiming it is being processed.",
|
|
11
|
+
"Questions and reports Butler sends you are written in plain words, separate from his internal notes.",
|
|
12
|
+
"A web endpoint must fail two health checks in a row before Butler treats it as down, and informational Unraid notices are ignored.",
|
|
13
|
+
"The house digest stops repeating the same stuck items as new.",
|
|
14
|
+
"Butler sends at most three unprompted messages a day and none between 23:00 and 08:00, except disk, parity and array alerts.",
|
|
15
|
+
"Server events blocked by Butler's daily allowance or failure pause now wait and retry instead of being dropped, and disk, parity and array events are never held back.",
|
|
16
|
+
"The health acceptance probe keeps checking a web endpoint once per sweep, so its scripted scenarios still match what Butler does.",
|
|
17
|
+
"Media request and request-status answers use the same plain shelf states (downloading, in Radarr or Sonarr but not downloading, available) instead of \"processing\"."
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.1.0-alpha.956",
|
|
22
|
+
"changes": [
|
|
23
|
+
"A new credit gateway (ouro gateway serve) lets agents use one master key while it spends several Anthropic API keys in a fixed order, moving to the next key only when one runs out of credits, with a health page and a runbook for running it next to the Butler on Unraid.",
|
|
24
|
+
"The Anthropic provider can now use a plain API key and a custom base URL (ouro auth --provider anthropic --api-key-file <path> --base-url <url>, which reads the key from a file or standard input and never from the command line), so an agent can run on API credits through the credit gateway; the existing setup-token login works as before.",
|
|
25
|
+
"The credit gateway refuses any request path that would send a key to a host other than the Anthropic API."
|
|
26
|
+
]
|
|
27
|
+
},
|
|
4
28
|
{
|
|
5
29
|
"version": "0.1.0-alpha.955",
|
|
6
30
|
"changes": [
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Credit gateway runbook
|
|
2
|
+
|
|
3
|
+
The credit gateway lets the Butler run on the API credits of several Claude Max plans through one key. Agents present a single master key; the gateway holds the real Anthropic API keys and spends them in a fixed order: it uses the first key until the API says that key's credit balance is too low, then the second, then the third. It never spreads load across keys.
|
|
4
|
+
|
|
5
|
+
It runs from the same image as the Butler (no separate image), as a second container named `ouro-credit-gateway`. Both containers use host networking, so the gateway listens on `127.0.0.1:8787` and the Butler reaches it at `http://127.0.0.1:8787`. Nothing is published to the network.
|
|
6
|
+
|
|
7
|
+
## How it behaves
|
|
8
|
+
|
|
9
|
+
- `master.key` in the keys folder is the key clients must send, as `x-api-key` or `Authorization: Bearer`. A wrong or missing key gets HTTP 401 `invalid x-api-key`.
|
|
10
|
+
- Every other `*.key` file is an upstream Anthropic API key. The gateway uses them in file-name order (`acct1.key`, `acct2.key`, `acct3.key`). The label in logs and health is the file name without `.key`. Key contents are never logged.
|
|
11
|
+
- The gateway reads the keys once at start. Restart the container after changing a key file.
|
|
12
|
+
- When the API answers HTTP 400, 402 or 403 with "credit balance is too low", the gateway skips that key for the cooldown (60 minutes by default), logs `gateway: <label> out of credits; moving on`, and retries the same request with the next key. After the cooldown it tries the key again, which is how a key comes back once its org's credits refresh. A rejected credit check is not billed.
|
|
13
|
+
- The skip list is saved in `gateway-state.json` in the keys folder (mode 0600), so a restart remembers it. Delete that file to retry every key at once.
|
|
14
|
+
- When every key is out of credits, clients get HTTP 400 with the API's last credit-balance error.
|
|
15
|
+
- If the API cannot be reached, clients get HTTP 502 and the gateway does not move to the next key.
|
|
16
|
+
- Every request logs one line: `gateway: <label> <method> <path> <status> <ms>ms`. Read them with `docker logs ouro-credit-gateway`.
|
|
17
|
+
|
|
18
|
+
## One-time setup (as root on the Unraid host)
|
|
19
|
+
|
|
20
|
+
1. Create the keys folder, owned by the image's user (UID/GID 10001) and closed to everyone else:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
install -d -o 10001 -g 10001 -m 0700 /mnt/user/appdata/credit-gateway /mnt/user/appdata/credit-gateway/keys
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
2. Put one upstream API key per account in the folder, one key per file, each file mode 0600. Copy the key files in rather than pasting keys on the command line, so they stay out of shell history:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
cd /mnt/user/appdata/credit-gateway/keys
|
|
30
|
+
install -o 10001 -g 10001 -m 0600 /path/to/acct1.key acct1.key
|
|
31
|
+
install -o 10001 -g 10001 -m 0600 /path/to/acct2.key acct2.key
|
|
32
|
+
install -o 10001 -g 10001 -m 0600 /path/to/acct3.key acct3.key
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
3. Generate the master key:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
cd /mnt/user/appdata/credit-gateway/keys
|
|
39
|
+
(umask 077; printf 'ouro-gw-%s\n' "$(openssl rand -hex 32)" > master.key)
|
|
40
|
+
chown 10001:10001 master.key
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
4. Check the result. Every file should be `-rw------- 10001 10001`:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
ls -ln /mnt/user/appdata/credit-gateway/keys
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The folder must stay writable by UID 10001, because the gateway saves `gateway-state.json` there.
|
|
50
|
+
|
|
51
|
+
## Run the gateway
|
|
52
|
+
|
|
53
|
+
Use the same image tag the Butler runs (the `<Repository>` in `sanctuary.xml`). The image's entrypoint starts the agent daemon, so override it with `--entrypoint node`. The image's built-in health check probes the agent, so replace it with a check of the gateway's own health URL:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
docker run -d --name ouro-credit-gateway \
|
|
57
|
+
--network host \
|
|
58
|
+
--restart unless-stopped \
|
|
59
|
+
--entrypoint node \
|
|
60
|
+
-v /mnt/user/appdata/credit-gateway/keys:/keys \
|
|
61
|
+
--health-cmd 'curl -fsS http://127.0.0.1:8787/health || exit 1' \
|
|
62
|
+
--health-interval 30s --health-timeout 5s --health-retries 3 \
|
|
63
|
+
ghcr.io/ourostack/ouroboros-butler:<same tag as ouro-butler> \
|
|
64
|
+
/opt/ouro/dist/heart/daemon/ouro-entry.js gateway serve --keys-dir /keys --host 127.0.0.1 --port 8787
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Port 8787 is also the default port of Readarr and its forks (such as Bookshelf). If `ss -ltn | grep 8787` shows it in use, pick a free port (for example 8788) and use it in `--port`, the health check and the Butler's `--base-url`.
|
|
68
|
+
|
|
69
|
+
Optional flags after `gateway serve`: `--cooldown-minutes <n>` (default 60), `--state <path>` (default `<keys-dir>/gateway-state.json`), `--upstream <url>` (default `https://api.anthropic.com`).
|
|
70
|
+
|
|
71
|
+
## Check it
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
docker inspect --format '{{.State.Status}} {{.State.Health.Status}}' ouro-credit-gateway
|
|
75
|
+
curl -fsS http://127.0.0.1:8787/health
|
|
76
|
+
docker logs --tail 20 ouro-credit-gateway
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`/health` needs no key and returns, for example, `{"ok":true,"keys":[{"label":"acct1","available":true},{"label":"acct2","available":false,"skipUntil":"2026-11-01T10:00:00.000Z"},{"label":"acct3","available":true}]}`.
|
|
80
|
+
|
|
81
|
+
## Point the Butler at it
|
|
82
|
+
|
|
83
|
+
Store the master key and the gateway URL as the Butler's Anthropic credentials (API-key mode). The key is read from a file or standard input, never from the command line:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
docker exec -i -u 10001 ouro-butler node /opt/ouro/dist/heart/daemon/ouro-entry.js \
|
|
87
|
+
auth --agent sanctuary --provider anthropic --api-key-file /dev/stdin --base-url http://127.0.0.1:8787 \
|
|
88
|
+
< /mnt/user/appdata/credit-gateway/keys/master.key
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Then select an Anthropic model for the Butler's lanes with `ouro use`. In API-key mode the agent sends a plain API key, with no Claude Code identity headers, no Claude Code system preamble and no token refresh.
|
|
92
|
+
|
|
93
|
+
## Change or rotate keys
|
|
94
|
+
|
|
95
|
+
Replace the file (keep owner 10001 and mode 0600), then `docker restart ouro-credit-gateway`. After rotating `master.key`, run the `auth` step above again so the Butler sends the new master key.
|
|
@@ -94,7 +94,7 @@ const prowlarr = (path, opts = {}) => req(PROWLARR.url, `/api/v1${path}`, { key:
|
|
|
94
94
|
// ------------------------------------------------------------------ mapping
|
|
95
95
|
|
|
96
96
|
// Jellyseerr media status codes.
|
|
97
|
-
const MEDIA_STATUS = { 1: "unknown", 2: "pending",
|
|
97
|
+
const MEDIA_STATUS = { 1: "unknown", 2: "pending", 4: "partially_available", 5: "available" }
|
|
98
98
|
const REQUEST_STATUS = { 1: "pending_approval", 2: "approved", 3: "declined", 4: "failed" }
|
|
99
99
|
|
|
100
100
|
const TMDB_GENRES = {
|
|
@@ -110,11 +110,25 @@ function genreNames(item) {
|
|
|
110
110
|
return ids.map((id) => TMDB_GENRES[id]).filter(Boolean)
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
// Jellyseerr's media status 3 only means Sonarr/Radarr track the title; it says nothing about
|
|
114
|
+
// a download or a request. Name what is actually known so nobody reads it as "processing".
|
|
115
|
+
function shelfStateOf(kind, info) {
|
|
116
|
+
if (info.status === 3) return info.downloadStatus?.length ? "downloading" : `in_${kind === "series" ? "sonarr" : "radarr"}_not_downloading`
|
|
117
|
+
return MEDIA_STATUS[info.status] ?? "not_on_shelf"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A request exists only if Jellyseerr lists one; the media status is not a request.
|
|
121
|
+
function requestStateOf(info) {
|
|
122
|
+
if (!Array.isArray(info.requests)) return info.status ? "unknown_check_media_request_status" : "never_requested"
|
|
123
|
+
if (info.requests.length === 0) return "never_requested"
|
|
124
|
+
return REQUEST_STATUS[info.requests[info.requests.length - 1].status] ?? "requested"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function shelfItem(item) {
|
|
114
128
|
const kind = item.mediaType === "tv" ? "series" : item.mediaType === "movie" ? "movie" : (item.firstAirDate ? "series" : "movie")
|
|
115
129
|
const date = item.releaseDate ?? item.firstAirDate ?? ""
|
|
116
130
|
const info = item.mediaInfo ?? {}
|
|
117
|
-
const status =
|
|
131
|
+
const status = shelfStateOf(kind, info)
|
|
118
132
|
return {
|
|
119
133
|
title: item.title ?? item.name ?? null,
|
|
120
134
|
year: date ? Number(date.slice(0, 4)) : null,
|
|
@@ -126,8 +140,8 @@ function shelfItem(item) {
|
|
|
126
140
|
// The two fields that stop a second call:
|
|
127
141
|
on_shelf: status === "available" || status === "partially_available",
|
|
128
142
|
shelf_state: status,
|
|
129
|
-
request_state: info
|
|
130
|
-
sonarr_id: info.externalServiceId ?? null,
|
|
143
|
+
request_state: requestStateOf(info),
|
|
144
|
+
sonarr_id: kind === "series" ? (info.externalServiceId ?? null) : null,
|
|
131
145
|
radarr_id: kind === "movie" ? (info.externalServiceId ?? null) : null,
|
|
132
146
|
poster_path: item.posterPath ? `https://image.tmdb.org/t/p/w500${item.posterPath}` : null,
|
|
133
147
|
vote_average: item.voteAverage ?? null,
|
|
@@ -385,7 +399,7 @@ async function mediaRequest(a) {
|
|
|
385
399
|
|
|
386
400
|
const existing = await findExistingRequest(tmdbId, kind)
|
|
387
401
|
if (existing?.hasRequest) {
|
|
388
|
-
const shelf =
|
|
402
|
+
const shelf = shelfStateOf(kind, existing.media ?? {})
|
|
389
403
|
return {
|
|
390
404
|
resolved: true, title: resolvedTitle ?? existing.media?.title ?? null, year: resolvedYear, kind, tmdb_id: tmdbId,
|
|
391
405
|
request_id: `jellyseerr:${existing.id}`,
|
|
@@ -412,7 +426,7 @@ async function mediaRequest(a) {
|
|
|
412
426
|
approval_state: REQUEST_STATUS[created.status] ?? "submitted",
|
|
413
427
|
duplicate_of: null,
|
|
414
428
|
jellyseerr_status: REQUEST_STATUS[created.status] ?? "submitted",
|
|
415
|
-
shelf_state:
|
|
429
|
+
shelf_state: created.media?.status ? shelfStateOf(kind, created.media) : "not_on_shelf",
|
|
416
430
|
submitted_at: created.createdAt,
|
|
417
431
|
tvdb_id: created.media?.tvdbId ?? null,
|
|
418
432
|
sonarr_id: kind === "series" ? (created.media?.externalServiceId ?? null) : null,
|
|
@@ -520,7 +534,7 @@ export async function mediaRequestStatus(a) {
|
|
|
520
534
|
|
|
521
535
|
const kind = request.type === "tv" ? "series" : "movie"
|
|
522
536
|
const media = request.media ?? {}
|
|
523
|
-
const shelf =
|
|
537
|
+
const shelf = shelfStateOf(kind, media)
|
|
524
538
|
const svcId = media.externalServiceId ?? null
|
|
525
539
|
|
|
526
540
|
const result = {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.957</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.loadGatewayKeys = loadGatewayKeys;
|
|
37
|
+
exports.loadGatewayState = loadGatewayState;
|
|
38
|
+
exports.saveGatewayState = saveGatewayState;
|
|
39
|
+
const fs = __importStar(require("node:fs"));
|
|
40
|
+
const path = __importStar(require("node:path"));
|
|
41
|
+
const runtime_1 = require("../nerves/runtime");
|
|
42
|
+
const MASTER_KEY_FILE = "master.key";
|
|
43
|
+
const KEY_SUFFIX = ".key";
|
|
44
|
+
function readTrimmed(filePath) {
|
|
45
|
+
try {
|
|
46
|
+
return fs.readFileSync(filePath, "utf8").trim();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Reads the master key and the upstream keys from a directory, once. Every `*.key` file other than
|
|
54
|
+
* `master.key` is an upstream key; the gateway drains them in file-name order.
|
|
55
|
+
*/
|
|
56
|
+
function loadGatewayKeys(keysDir) {
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = fs.readdirSync(keysDir);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
/* v8 ignore next -- defensive: fs always throws Error instances @preserve */
|
|
63
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
64
|
+
throw new Error(`gateway: cannot read keys directory ${keysDir}: ${reason}`);
|
|
65
|
+
}
|
|
66
|
+
const masterKey = readTrimmed(path.join(keysDir, MASTER_KEY_FILE));
|
|
67
|
+
if (!masterKey)
|
|
68
|
+
throw new Error(`gateway: ${path.join(keysDir, MASTER_KEY_FILE)} is missing or empty`);
|
|
69
|
+
const upstream = [];
|
|
70
|
+
for (const name of entries.filter((entry) => entry.endsWith(KEY_SUFFIX) && entry !== MASTER_KEY_FILE).sort()) {
|
|
71
|
+
const key = readTrimmed(path.join(keysDir, name));
|
|
72
|
+
if (!key)
|
|
73
|
+
throw new Error(`gateway: upstream key file ${path.join(keysDir, name)} is empty`);
|
|
74
|
+
upstream.push({ label: name.slice(0, -KEY_SUFFIX.length), key });
|
|
75
|
+
}
|
|
76
|
+
if (upstream.length === 0)
|
|
77
|
+
throw new Error(`gateway: no upstream *.key files in ${keysDir} (besides master.key)`);
|
|
78
|
+
(0, runtime_1.emitNervesEvent)({
|
|
79
|
+
component: "gateway",
|
|
80
|
+
event: "gateway.keys_loaded",
|
|
81
|
+
message: "credit gateway keys loaded",
|
|
82
|
+
meta: { keysDir, labels: upstream.map((entry) => entry.label) },
|
|
83
|
+
});
|
|
84
|
+
return { masterKey, upstream };
|
|
85
|
+
}
|
|
86
|
+
/** Loads the skip list. A missing or corrupt file starts empty; entries that are not valid times are dropped. */
|
|
87
|
+
function loadGatewayState(statePath) {
|
|
88
|
+
const exhausted = {};
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return { exhausted };
|
|
95
|
+
}
|
|
96
|
+
const raw = parsed && typeof parsed === "object" ? parsed.exhausted : undefined;
|
|
97
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
98
|
+
for (const [label, until] of Object.entries(raw)) {
|
|
99
|
+
if (typeof until === "string" && Number.isFinite(Date.parse(until)))
|
|
100
|
+
exhausted[label] = until;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { exhausted };
|
|
104
|
+
}
|
|
105
|
+
/** Writes the skip list with mode 0600, through a temporary file so a crash never leaves half a file. */
|
|
106
|
+
function saveGatewayState(statePath, state) {
|
|
107
|
+
const tmpPath = `${statePath}.${process.pid}.tmp`;
|
|
108
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
109
|
+
fs.chmodSync(tmpPath, 0o600);
|
|
110
|
+
fs.renameSync(tmpPath, statePath);
|
|
111
|
+
(0, runtime_1.emitNervesEvent)({
|
|
112
|
+
component: "gateway",
|
|
113
|
+
event: "gateway.state_saved",
|
|
114
|
+
message: "credit gateway state saved",
|
|
115
|
+
meta: { statePath, exhausted: Object.keys(state.exhausted) },
|
|
116
|
+
});
|
|
117
|
+
}
|