ccdeck 3.29.10 → 3.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
- package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
- package/dist/web/assets/index-C1YFI9f4.js +0 -298
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
package/dist/web/index.html
CHANGED
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
|
|
63
63
|
})();
|
|
64
64
|
</script>
|
|
65
|
-
<script type="module" crossorigin src="/assets/index-
|
|
66
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
65
|
+
<script type="module" crossorigin src="/assets/index-BVTWHDta.js"></script>
|
|
66
|
+
<link rel="stylesheet" crossorigin href="/assets/index-3vS6A0vt.css">
|
|
67
67
|
</head>
|
|
68
68
|
<body>
|
|
69
69
|
<div id="root"></div>
|
package/hook/hook.js
CHANGED
|
@@ -56,8 +56,9 @@ const PROVIDER = parseProvider(process.argv.slice(2));
|
|
|
56
56
|
* arrived short — `%TEMP%` under a shortened profile directory, which is what
|
|
57
57
|
* every GitHub Windows runner has — the two canonicalisers that exist to agree
|
|
58
58
|
* disagreed by a whole path: C:\Users\RUNNER~1\… against C:\Users\runneradmin\….
|
|
59
|
-
* canonicalWorkspace in src/server/
|
|
60
|
-
* the long form is the canonical one; all three sites name
|
|
59
|
+
* canonicalWorkspace in src/server/canonical-path.mjs says the rest of it,
|
|
60
|
+
* including why the long form is the canonical one; all three sites name
|
|
61
|
+
* `.native` out loud.
|
|
61
62
|
*
|
|
62
63
|
* Exported for that test: it is half of what `--workspace` means, and a
|
|
63
64
|
* predicate handed an already-canonical path cannot show that the caller
|
|
@@ -179,7 +180,7 @@ function cwdInWorkspace(cwd, workspace, platform = process.platform) {
|
|
|
179
180
|
* payload with no cwd — it is here because the rule has to be stated the same
|
|
180
181
|
* way on both sides to be pinned against the other one.
|
|
181
182
|
*
|
|
182
|
-
* src/server/log-
|
|
183
|
+
* src/server/log-election.mjs answers this same question, for the sessions the
|
|
183
184
|
* server builds itself out of Codex's rollout files, under the name
|
|
184
185
|
* codexCwdInWorkspace — this script is copied out of the package and run
|
|
185
186
|
* standalone, so it cannot import that copy. A test walks one table of paths
|
|
@@ -233,11 +234,11 @@ function isAlive(pid) {
|
|
|
233
234
|
* The platform is a parameter, like cwdInWorkspace's, so the case-folding half
|
|
234
235
|
* is testable from any machine.
|
|
235
236
|
*
|
|
236
|
-
* src/server/log-
|
|
237
|
-
* the ones the server builds itself from Codex's rollout files —
|
|
238
|
-
* script is copied out of the package and cannot import it. A test
|
|
239
|
-
* two directly: they decide for the same decks, and a disagreement
|
|
240
|
-
* written twice or not at all.
|
|
237
|
+
* src/server/log-election.mjs repeats this rule for the events no hook
|
|
238
|
+
* delivers — the ones the server builds itself from Codex's rollout files —
|
|
239
|
+
* because this script is copied out of the package and cannot import it. A test
|
|
240
|
+
* compares the two directly: they decide for the same decks, and a disagreement
|
|
241
|
+
* is a line written twice or not at all.
|
|
241
242
|
*/
|
|
242
243
|
function electWriters(decks, platform = process.platform) {
|
|
243
244
|
const byLog = new Map();
|
|
@@ -527,7 +528,7 @@ function prove(d, cb, attempt = 0) {
|
|
|
527
528
|
* The election has to be decided over the decks that are actually going to be
|
|
528
529
|
* handed the payload, and the only thing that establishes that is the handshake.
|
|
529
530
|
* So: prove, then elect, then post. It costs no extra round trip, only this
|
|
530
|
-
* ordering, and it is the same reordering src/server/
|
|
531
|
+
* ordering, and it is the same reordering src/server/live-decks.mjs makes in
|
|
531
532
|
* readLiveDecks for the Codex rollouts no hook ever sees.
|
|
532
533
|
*
|
|
533
534
|
* A FAILED CHALLENGE ON ITS OWN IS STILL NOT PROOF THE DECK IS GONE. A dead pid
|
|
@@ -793,10 +794,10 @@ function readRecord(file, resolvedCwd, found, done) {
|
|
|
793
794
|
// every tool call, and nothing removes the record: pid 1 is init, so
|
|
794
795
|
// isAlive is true forever and the unlink below never fires.
|
|
795
796
|
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
// half, which is the one that belongs in
|
|
799
|
-
// to throw.
|
|
797
|
+
// sweepStaleDiscovery, in src/server/stale-discovery.mjs, reads these same
|
|
798
|
+
// files and always refused them. Two readers of one directory disagreeing
|
|
799
|
+
// is the bug; this is the stronger half, which is the one that belongs in
|
|
800
|
+
// the process that cannot afford to throw.
|
|
800
801
|
//
|
|
801
802
|
// IT IS INSIDE THE fs CALLBACK NOW, and that is where it has to be rather
|
|
802
803
|
// than merely where it ended up. The reads are asynchronous (#1018), so the
|
|
@@ -1040,7 +1041,7 @@ function main() {
|
|
|
1040
1041
|
// /private/tmp/proj) and then comes back empty after a restart. Silently —
|
|
1041
1042
|
// replayLog does not count or warn about an out-of-scope line.
|
|
1042
1043
|
//
|
|
1043
|
-
// The Codex watcher already stores the canonical form (
|
|
1044
|
+
// The Codex watcher already stores the canonical form (codex-watch.mjs,
|
|
1044
1045
|
// `cwd: await canonicalCwd(...)`, the same resolve+realpath this is), which
|
|
1045
1046
|
// is why the two providers disagreed on one board. One spelling in the log
|
|
1046
1047
|
// is the whole fix.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.30.0",
|
|
4
4
|
"description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -59,6 +59,34 @@
|
|
|
59
59
|
"3.29.7",
|
|
60
60
|
"3.29.9"
|
|
61
61
|
],
|
|
62
|
+
"3.30.0": [
|
|
63
|
+
{
|
|
64
|
+
"title": "🧮 Token counts and costs are no longer doubled",
|
|
65
|
+
"body": "Claude Code writes a reply that has thinking, text and tool calls as several transcript lines, each carrying the whole request's usage. The deck added up every line, so a session's tokens and cost on its card came out close to twice what it was billed, and the context panel counted each part of a reply as its own message. Each request is now counted once. Expect the totals on your cards to come down."
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"title": "⏳ Claude's saved limit resets on the Usage panel",
|
|
69
|
+
"body": "When your Claude account has free limit resets to spend (\"Reset for free\" in Claude's Settings → Usage), the Claude card now says how many there are and when the first one expires, the way the Codex card already did. The deck only reports them; you spend them in Claude's own settings."
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"title": "🔑 LAN invites need the other deck updated",
|
|
73
|
+
"body": "An invite's code is now a long random secret instead of six digits, so nobody else on the network can work it out and pair in your place, and a wrong code is refused by name. An invite made on this version can only be joined from a deck on this version or later: an older deck answers \"That is not an invite\". Update that deck, or make the invite on the older one and join it here."
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"title": "🔕 AGENTS_DECK_NO_NOTIFY keeps Browser Watch quiet too",
|
|
77
|
+
"body": "A deck started with AGENTS_DECK_NO_NOTIFY=1 still raised a desktop notification for every new Browser Watch finding. It no longer does. The finding is still in the Browser Watch panel and its log, which says why no notification came."
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"title": "🛠️ Smaller fixes",
|
|
81
|
+
"body": "Delete removes the card you just clicked. A LAN address you typed stays in the list when a round cannot reach it, and the status line under the LAN switch now agrees with the rows below it. In the desktop app, a dismissed \"update is ready\" notice stays dismissed after you answer the first-launch question. Sizes read the same in every panel (\"1.1 KB\"). Faded text stays readable, a keyboard can see the scrollbar, and numbers use a monospaced font on Windows and Linux as well."
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
"3.29.11": [
|
|
85
|
+
{
|
|
86
|
+
"title": "💲 Claude Sonnet 5.5 has a price",
|
|
87
|
+
"body": "Sessions on Claude Sonnet 5.5 showed \"not priced\" on the board, and the Projects report could only show a minimum for them. They are now priced at Anthropic's published rate, the same as Sonnet 5: $2 per million input tokens and $10 per million output tokens."
|
|
88
|
+
}
|
|
89
|
+
],
|
|
62
90
|
"3.29.10": [
|
|
63
91
|
{
|
|
64
92
|
"title": "💲 Claude Opus 5.5 has a price",
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
// The account cards show account-wide rate-limit utilisation, which has no
|
|
5
5
|
// project dimension and cannot be sliced. This is the other axis: for one
|
|
6
6
|
// account, the tokens it spent per project, which the web side prices with the
|
|
7
|
-
// board's own
|
|
8
|
-
// server is plain .mjs with no build step and cannot
|
|
9
|
-
// table, and keeping one source of prices beats a second
|
|
10
|
-
// this file tallies TOKENS per (account, project, day,
|
|
11
|
-
// multiplies by the rates it already owns.
|
|
7
|
+
// board's own rates (rate-table.ts, through pricing.ts's costForUsage). Cost is
|
|
8
|
+
// NOT computed here — the server is plain .mjs with no build step and cannot
|
|
9
|
+
// import the .ts rate table, and keeping one source of prices beats a second
|
|
10
|
+
// copy that drifts. So this file tallies TOKENS per (account, project, day,
|
|
11
|
+
// model) and the client multiplies by the rates it already owns.
|
|
12
12
|
//
|
|
13
13
|
// Attribution is per MESSAGE, not per session. A running session migrates to a
|
|
14
14
|
// newly-activated account on its next message, so a whole session can span two
|
|
@@ -25,10 +25,14 @@ import { open, stat, readdir, readFile, writeFile, mkdir } from "node:fs/promise
|
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
import { join, resolve, basename, dirname, relative } from "node:path";
|
|
27
27
|
import { StringDecoder } from "node:string_decoder";
|
|
28
|
-
import { renameWithRetry } from "./
|
|
28
|
+
import { renameWithRetry } from "./atomic-write.mjs";
|
|
29
29
|
import { ccProjectSlug, claudeConfigDir } from "./claude-dir.mjs";
|
|
30
30
|
import { accountKey } from "./lan-sync.mjs";
|
|
31
31
|
import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from "./swap-log.mjs";
|
|
32
|
+
// The two rules the live transcript scan counts a line's usage by, so this
|
|
33
|
+
// rollup and a session's card cannot disagree about which lines billed
|
|
34
|
+
// anything — see transcript-scan.mjs.
|
|
35
|
+
import { hasSpend, repeatsRequestUsage } from "./transcript-scan.mjs";
|
|
32
36
|
|
|
33
37
|
/** The pseudo-account for messages the swap log cannot place — everything
|
|
34
38
|
* before tracking began, or a gap. Shown once, apart from any real account, so
|
|
@@ -80,6 +84,16 @@ export function countersFrom(usage) {
|
|
|
80
84
|
};
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
/** The same counters under the names a usage block gives them, which is the
|
|
88
|
+
* shape transcript-scan.mjs's hasSpend reads. */
|
|
89
|
+
function asUsageTotals(c) {
|
|
90
|
+
return {
|
|
91
|
+
input_tokens: c.i, output_tokens: c.o,
|
|
92
|
+
cache_read_input_tokens: c.cr, cache_creation_input_tokens: c.cc,
|
|
93
|
+
ephemeral_1h_input_tokens: c.c1h, ephemeral_5m_input_tokens: c.c5m,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
83
97
|
function addInto(dst, src) {
|
|
84
98
|
dst.i += src.i; dst.o += src.o; dst.cr += src.cr;
|
|
85
99
|
dst.cc += src.cc; dst.c1h += src.c1h; dst.c5m += src.c5m;
|
|
@@ -187,16 +201,16 @@ export function foldLine(tally, line, timeline, from) {
|
|
|
187
201
|
if (!line || !line.includes('"usage"')) return;
|
|
188
202
|
let obj = null;
|
|
189
203
|
try { obj = JSON.parse(line); } catch { return; }
|
|
190
|
-
// Claude writes one assistant record for each content block in a request
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
if (obj
|
|
204
|
+
// Claude writes one assistant record for each content block in a request,
|
|
205
|
+
// and every block repeats the request's usage, so only block 0 is billable
|
|
206
|
+
// — see repeatsRequestUsage.
|
|
207
|
+
if (repeatsRequestUsage(obj)) return;
|
|
194
208
|
const usage = obj?.message?.usage;
|
|
195
209
|
if (!usage || typeof usage !== "object") return;
|
|
196
210
|
const ts = Date.parse(obj?.timestamp);
|
|
197
211
|
if (!Number.isFinite(ts)) return;
|
|
198
212
|
const c = countersFrom(usage);
|
|
199
|
-
if (!(c
|
|
213
|
+
if (!hasSpend(asUsageTotals(c))) return; // a usage block that billed nothing
|
|
200
214
|
const model = typeof obj?.message?.model === "string" ? obj.message.model : "";
|
|
201
215
|
const lineCwd = (typeof obj?.cwd === "string" && obj.cwd) ? obj.cwd : "";
|
|
202
216
|
const cwd = projectPath(folderOf(lineCwd, from)); // a worktree counts under the repo it checks out
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// The HTTP face of the Claude accounts surface: the roster, the switch, sign-in
|
|
2
|
+
// and the store's admin verbs, auto-switch, and the per-account Projects report.
|
|
3
|
+
//
|
|
4
|
+
// These lived in src/server/index.mjs, on either side of the self-update pin,
|
|
5
|
+
// and they are the thinnest layer in the deck: each one reads a query or a
|
|
6
|
+
// body, hands it to claude-accounts.mjs, cswap-admin.mjs, cswap-auto.mjs or the
|
|
7
|
+
// project rollup, and answers with what came back. Every one of those modules
|
|
8
|
+
// is still imported lazily and by the same package-root URL PINNED_MODULES
|
|
9
|
+
// loads, so moving the routes here puts no import on the path to a listening
|
|
10
|
+
// socket and takes none out of the pin.
|
|
11
|
+
//
|
|
12
|
+
// Four things are exported beyond the handlers. Three because index.mjs reaches
|
|
13
|
+
// for them: wireStaleCopyRepair, which startServer runs to hand the roster its
|
|
14
|
+
// stale-copy repair, cswapAutoModule, which it uses to start auto-switch, and
|
|
15
|
+
// getProjectRollup, which it starts at boot. The fourth is CHECKS_IMPORTS,
|
|
16
|
+
// which lan-deck.mjs reads because a LAN round checks the imports it lands the
|
|
17
|
+
// same way the paste box does.
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
20
|
+
import { readBody, send } from "./http-io.mjs";
|
|
21
|
+
|
|
22
|
+
// Resolved the way pinned-build.mjs resolves it, from a file in the same
|
|
23
|
+
// directory, so every lazy import below is the URL the pin has already evaluated.
|
|
24
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
25
|
+
|
|
26
|
+
// Whether every import is followed by checkImports, which only asks on a Mac.
|
|
27
|
+
// When it does, the import skips its own detached collection — see importAccount.
|
|
28
|
+
export const CHECKS_IMPORTS = process.platform === "darwin";
|
|
29
|
+
|
|
30
|
+
export async function handleClaudeAccounts(req, res) {
|
|
31
|
+
const { fetchClaudeAccounts } = await import(
|
|
32
|
+
pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href
|
|
33
|
+
);
|
|
34
|
+
const url = new URL(req.url, "http://localhost");
|
|
35
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
36
|
+
send(res, 200, await fetchClaudeAccounts({ force }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function handleClaudeAccountSwitch(req, res) {
|
|
40
|
+
const { switchClaudeAccount, invalidateClaudeAccountsCache } = await import(
|
|
41
|
+
pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href
|
|
42
|
+
);
|
|
43
|
+
const body = await readBody(req, res).catch(() => null);
|
|
44
|
+
let parsed = null;
|
|
45
|
+
try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
|
|
46
|
+
if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
47
|
+
|
|
48
|
+
const result = await switchClaudeAccount(parsed.account);
|
|
49
|
+
// The active account just moved; the next poll should see it immediately
|
|
50
|
+
// rather than serving the pre-switch roster for another few seconds.
|
|
51
|
+
invalidateClaudeAccountsCache();
|
|
52
|
+
// The 5h/7d percentages moved with it — they are read for whichever account is
|
|
53
|
+
// active — and only this side knows: /api/quota is polled by the usage panel,
|
|
54
|
+
// which has no idea the accounts panel just switched and so never sends
|
|
55
|
+
// ?refresh=1. Without this the two panels sit on one screen disagreeing about
|
|
56
|
+
// the same account for a full poll cycle, and the stale one is the one the
|
|
57
|
+
// user just acted on. Only on a switch that took: a refused one left the
|
|
58
|
+
// account where it was, and throwing away a reading we paid for costs a real
|
|
59
|
+
// answer to fix nothing.
|
|
60
|
+
if (result.ok) {
|
|
61
|
+
const { invalidateQuotaCache } = await import(
|
|
62
|
+
pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
|
|
63
|
+
);
|
|
64
|
+
invalidateQuotaCache();
|
|
65
|
+
// Note who became active, and when, for the account-projects report. Best
|
|
66
|
+
// effort — a failed log write must never fail the switch — so it is not
|
|
67
|
+
// awaited into the response.
|
|
68
|
+
import(pathToFileURL(join(PKG_ROOT, "src/server/swap-log.mjs")).href)
|
|
69
|
+
.then(({ recordSwap }) => recordSwap(parsed.account, "manual"))
|
|
70
|
+
.catch(() => {});
|
|
71
|
+
}
|
|
72
|
+
send(res, result.ok ? 200 : 400, result);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The account-projects rollup, started once and shared. Its timer is unref'd
|
|
76
|
+
// inside start(), so holding the instance here never keeps the process alive.
|
|
77
|
+
let _projectRollup = null;
|
|
78
|
+
export function getProjectRollup() {
|
|
79
|
+
if (!_projectRollup) {
|
|
80
|
+
_projectRollup = import(pathToFileURL(join(PKG_ROOT, "src/server/account-projects.mjs")).href)
|
|
81
|
+
.then(m => { const r = m.createProjectRollup(); r.start().catch(() => {}); return r; });
|
|
82
|
+
}
|
|
83
|
+
return _projectRollup;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The "Projects" report for one account: how many tokens it spent per project
|
|
88
|
+
* over a window. The server tallies only tokens (per model); the web side
|
|
89
|
+
* prices them with its own table, so cost never lives in two places. `num` is
|
|
90
|
+
* the account's slot, resolved to its `(email, org)` key here; `days` is 1, 7,
|
|
91
|
+
* 30, or 0 for all tracked.
|
|
92
|
+
*/
|
|
93
|
+
export async function handleAccountProjects(req, res) {
|
|
94
|
+
const url = new URL(req.url, "http://localhost");
|
|
95
|
+
const num = Number(url.searchParams.get("num"));
|
|
96
|
+
const d = Number(url.searchParams.get("days"));
|
|
97
|
+
const days = d === 1 ? 1 : d === 30 ? 30 : d === 0 ? 0 : 7;
|
|
98
|
+
if (!Number.isInteger(num) || num <= 0) return send(res, 400, { ok: false, reason: "bad_account" });
|
|
99
|
+
const { identityForSlot } = await import(pathToFileURL(join(PKG_ROOT, "src/server/swap-log.mjs")).href);
|
|
100
|
+
const id = await identityForSlot(num);
|
|
101
|
+
if (!id) return send(res, 400, { ok: false, reason: "bad_account" });
|
|
102
|
+
const { accountKey } = await import(pathToFileURL(join(PKG_ROOT, "src/server/lan-sync.mjs")).href);
|
|
103
|
+
const rollup = await getProjectRollup();
|
|
104
|
+
const report = await rollup.report(accountKey(id.email, id.orgUuid), days);
|
|
105
|
+
send(res, 200, { ok: true, ...report });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function cswapAdminModule() {
|
|
109
|
+
return import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-admin.mjs")).href);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Hand cswap-admin's autoRecapture to the roster read, so a `stale-copy` row
|
|
114
|
+
* carries its own repair rather than a button — see repairStaleCopyWith.
|
|
115
|
+
*
|
|
116
|
+
* startServer calls this once per boot on a deck that watches Claude Code, and
|
|
117
|
+
* does not wait for it. It lived in startServer itself and moved here, beside
|
|
118
|
+
* cswapAdminModule and the package root it imports the pair by; the body is
|
|
119
|
+
* unchanged.
|
|
120
|
+
*/
|
|
121
|
+
export function wireStaleCopyRepair() {
|
|
122
|
+
// THIS WIRING IS WHERE AN IMPORT CYCLE SHOWED UP AS A SILENT NO-OP, and it
|
|
123
|
+
// is the cycle that has been fixed rather than this line — see
|
|
124
|
+
// claude-identity.mjs.
|
|
125
|
+
//
|
|
126
|
+
// claude-accounts.mjs used to take `currentIdentity` from cswap-admin.mjs
|
|
127
|
+
// while cswap-admin.mjs took `backupRoot`, `invalidateClaudeAccountsCache`
|
|
128
|
+
// and `verdictNow` back, which was the only static import cycle in
|
|
129
|
+
// src/server. Two dynamic imports entering a cycle concurrently are each
|
|
130
|
+
// handed the other module's HALF-BUILT namespace rather than waiting for
|
|
131
|
+
// it, and a half-built namespace has no exports on it at all: measured on
|
|
132
|
+
// CI, both came back with zero keys, so `accounts.repairStaleCopyWith` was
|
|
133
|
+
// a TypeError in a promise nothing awaits. The repair was never wired and
|
|
134
|
+
// nothing said so — every test passed, and the run exited 1 on an unhandled
|
|
135
|
+
// rejection alone.
|
|
136
|
+
//
|
|
137
|
+
// Several importers reach this pair within a few ticks at boot —
|
|
138
|
+
// startServer's `cswapAutoModule()` imports claude-accounts.mjs too, and
|
|
139
|
+
// `startServer` can be called again before this has settled — so it was a
|
|
140
|
+
// timing defect that any change to those import lists could trip, and
|
|
141
|
+
// sequencing one call site was never going to be enough.
|
|
142
|
+
//
|
|
143
|
+
// Asked for one at a time anyway, which is now belt as well as braces: with
|
|
144
|
+
// no cycle left, a concurrent pair would simply wait for each other.
|
|
145
|
+
// boot-module-graph.test.ts asserts the braces — that src/server has no
|
|
146
|
+
// import cycles at all — rather than trying to police call sites.
|
|
147
|
+
void (async () => {
|
|
148
|
+
let accounts, admin;
|
|
149
|
+
// An import that genuinely fails stays tolerated, exactly as the
|
|
150
|
+
// `() => {}` this replaced tolerated it: the wiring is best-effort. A
|
|
151
|
+
// namespace that arrives WITHOUT the function is a different thing and
|
|
152
|
+
// must stay loud, because that is the failure described above.
|
|
153
|
+
try {
|
|
154
|
+
accounts = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
|
|
155
|
+
admin = await cswapAdminModule();
|
|
156
|
+
} catch { return; }
|
|
157
|
+
accounts.repairStaleCopyWith(admin.autoRecapture);
|
|
158
|
+
})();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Reading the login's progress. The browser polls this while its dialog is
|
|
162
|
+
// open, the same way the upgrade notice polls /api/version.
|
|
163
|
+
export async function handleAccountLoginState(_req, res) {
|
|
164
|
+
const { loginState } = await cswapAdminModule();
|
|
165
|
+
send(res, 200, { ok: true, ...loginState() });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Everything that changes the account store, behind one verb switch — the shape
|
|
170
|
+
* handleCswapAutoAction already uses.
|
|
171
|
+
*
|
|
172
|
+
* The sign-in code is the one field here that is a credential. It is read out
|
|
173
|
+
* of the body, handed straight to the child's stdin, and never logged, echoed
|
|
174
|
+
* back, or written anywhere — which is also why this is a POST body and not a
|
|
175
|
+
* query parameter.
|
|
176
|
+
*/
|
|
177
|
+
export async function handleClaudeAccountAdmin(req, res) {
|
|
178
|
+
const admin = await cswapAdminModule();
|
|
179
|
+
const body = await readBody(req, res).catch(() => null);
|
|
180
|
+
let parsed = null;
|
|
181
|
+
try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
|
|
182
|
+
if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
183
|
+
|
|
184
|
+
let result;
|
|
185
|
+
switch (parsed.action) {
|
|
186
|
+
case "login": result = await admin.startLogin({ email: parsed.email }); break;
|
|
187
|
+
case "login-code": result = await admin.submitLoginCode(parsed.code); break;
|
|
188
|
+
case "login-cancel": result = await admin.cancelLogin(); break;
|
|
189
|
+
// `accounts` is a chosen set, `account` the single row's own button. Both
|
|
190
|
+
// go to shareAccounts, which treats one account as a bundle of one so the
|
|
191
|
+
// two entry points cannot drift into two envelope shapes.
|
|
192
|
+
case "share": result = await admin.shareAccounts(parsed.accounts ?? parsed.account); break;
|
|
193
|
+
// `only` names one account inside the pasted bundle, and is the only way
|
|
194
|
+
// `force` is honoured at all - see importAccount for why the pair is
|
|
195
|
+
// required rather than the flag alone.
|
|
196
|
+
case "import": {
|
|
197
|
+
const out = await admin.importAccount(parsed.blob, {
|
|
198
|
+
force: parsed.force === true,
|
|
199
|
+
only: parsed.only ?? null,
|
|
200
|
+
collect: !CHECKS_IMPORTS,
|
|
201
|
+
});
|
|
202
|
+
// Checked the way a LAN round's arrivals are (#1244): `cswap import`
|
|
203
|
+
// exiting 0 on a Mac is not proof this process can read what it wrote.
|
|
204
|
+
result = out?.ok ? { ...out, results: await admin.checkImportResults(out.results) } : out;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case "remove": result = await admin.removeAccount(parsed.account); break;
|
|
208
|
+
// #721. Re-captures the active slot's credentials in place; see
|
|
209
|
+
// recaptureActive for why this is not a login and cannot become one.
|
|
210
|
+
case "recapture": result = await admin.recaptureActive(); break;
|
|
211
|
+
case "alias": result = await admin.setAlias(parsed.account, parsed.alias); break;
|
|
212
|
+
case "move": result = await admin.moveAccount(parsed.account, parsed.slot); break;
|
|
213
|
+
default: return send(res, 400, { ok: false, reason: "unknown_action" });
|
|
214
|
+
}
|
|
215
|
+
send(res, result.ok ? 200 : 400, result);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function cswapAutoModule() {
|
|
219
|
+
return import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-auto.mjs")).href);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function handleCswapAuto(req, res) {
|
|
223
|
+
const { autoStatus } = await cswapAutoModule();
|
|
224
|
+
send(res, 200, await autoStatus());
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* One POST for every auto-switch control, keyed by `action`. Each one can move
|
|
229
|
+
* the user's live Claude account or change when it moves, so nothing here is
|
|
230
|
+
* reachable by GET.
|
|
231
|
+
*/
|
|
232
|
+
export async function handleCswapAutoAction(req, res) {
|
|
233
|
+
const mod = await cswapAutoModule();
|
|
234
|
+
const body = await readBody(req, res).catch(() => null);
|
|
235
|
+
let parsed = null;
|
|
236
|
+
try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
|
|
237
|
+
if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
238
|
+
|
|
239
|
+
let result;
|
|
240
|
+
switch (parsed.action) {
|
|
241
|
+
case "enable":
|
|
242
|
+
result = await mod.setAutoEnabled(parsed.enabled === true);
|
|
243
|
+
break;
|
|
244
|
+
case "setting":
|
|
245
|
+
result = await mod.setCswapConfig(String(parsed.key ?? ""), parsed.value);
|
|
246
|
+
break;
|
|
247
|
+
case "account":
|
|
248
|
+
result = await mod.setAccountEnabled(parsed.account, parsed.enabled === true);
|
|
249
|
+
break;
|
|
250
|
+
default:
|
|
251
|
+
return send(res, 400, { ok: false, reason: "unknown_action" });
|
|
252
|
+
}
|
|
253
|
+
send(res, result.ok ? 200 : 400, result);
|
|
254
|
+
}
|
package/src/server/activity.mjs
CHANGED
|
@@ -23,9 +23,9 @@ const CLOSES = new Set(["Stop", "SessionEnd"]);
|
|
|
23
23
|
|
|
24
24
|
/** More turns open at once than any machine runs — the same figure as the
|
|
25
25
|
* deck's own working limit for the sessions it tracks (MAX_TRACKED_SESSIONS in
|
|
26
|
-
*
|
|
27
|
-
* pushEvent for every live event, and `/api/event` takes them without
|
|
28
|
-
* credential. presence.mjs caps its map of tab ids for the same reason.
|
|
26
|
+
* session-tracking.mjs). A bound because the ids arrive from outside: this is
|
|
27
|
+
* fed from pushEvent for every live event, and `/api/event` takes them without
|
|
28
|
+
* a credential. presence.mjs caps its map of tab ids for the same reason.
|
|
29
29
|
*
|
|
30
30
|
* Past it the turn heard from least recently goes, and that costs `busy` no
|
|
31
31
|
* accuracy at all: the map is kept in the order turns were last heard from
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// What the events log's append queue is allowed to weigh (#1030): the ceiling
|
|
2
|
+
// on lines accepted and not yet written, the charge each line carries until
|
|
3
|
+
// its write settles, and what the queue refused at the door because of it.
|
|
4
|
+
//
|
|
5
|
+
// Moved out of log-writer.mjs unchanged. appendLogLine asks queueIsFull at the
|
|
6
|
+
// door, then either counts a refusal (noteRefused) or takes the line's charge
|
|
7
|
+
// (takeCharge) and gives it back once the write has landed or failed
|
|
8
|
+
// (releaseCharge). log-writer.mjs re-exports MAX_PENDING_APPEND_CHARS and
|
|
9
|
+
// appendQueueStats, which is where the suite reaches them.
|
|
10
|
+
import { PRODUCT } from "./brand.mjs";
|
|
11
|
+
|
|
12
|
+
// ─── What the queue is allowed to weigh ───────────────────────────────────
|
|
13
|
+
//
|
|
14
|
+
// The chain in log-writer.mjs ORDERS appends. Nothing bounded them. Every line
|
|
15
|
+
// handed over is retained by the closure that will eventually write it, and
|
|
16
|
+
// the only brake on how many of those exist at once is how fast write(2)
|
|
17
|
+
// returns — while pushEvent hands them over fire-and-forget and answers
|
|
18
|
+
// `{ok:true, seq}` on the next statement, so `POST /api/event` admits lines as
|
|
19
|
+
// fast as a socket can deliver them.
|
|
20
|
+
//
|
|
21
|
+
// MEASURED (Linux 7.0 / Node 24.21 / ext4 on NVMe), eight sockets posting 1 MB
|
|
22
|
+
// `tool_response` bodies to a sandboxed deck for four seconds, sampled twice a
|
|
23
|
+
// second:
|
|
24
|
+
//
|
|
25
|
+
// t=1.0s log_MB=19 rss_MB=881
|
|
26
|
+
// t=2.5s log_MB=51 rss_MB=1741
|
|
27
|
+
// t=4.1s log_MB=85 rss_MB=2260 <- ingest stops; 1540 MB acknowledged
|
|
28
|
+
// t=5.5s log_MB=1135 rss_MB=935
|
|
29
|
+
// t=6.0s log_MB=1540 rss_MB=546 <- queue finally drained
|
|
30
|
+
//
|
|
31
|
+
// 85 MB on disk against 1540 MB the deck had said it had: 1455 MB of serialized
|
|
32
|
+
// lines held in the heap, and an RSS peak of 2260 MB — 17x MAX_BUFFER_CHARS,
|
|
33
|
+
// the budget the ring beside this keeps exactly. The disk is not the amplifier;
|
|
34
|
+
// the event loop is. Ingest and the writer share it, so the log grew at 21 MB/s
|
|
35
|
+
// while the posting continued and at 800 MB/s the instant it stopped.
|
|
36
|
+
//
|
|
37
|
+
// The same burst under `--max-old-space-size=1024`:
|
|
38
|
+
//
|
|
39
|
+
// FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
|
|
40
|
+
// [deck exited code=null sig=SIGABRT] 3.2s in, 50 MB written
|
|
41
|
+
//
|
|
42
|
+
// That abort is not catchable, so the SSE stream, the hook ingest and the log
|
|
43
|
+
// stop together — and `/api/event` is a deliberate OPEN_MUTATION, so this is a
|
|
44
|
+
// few hundred unauthenticated posts from any local process ending the deck.
|
|
45
|
+
// It is the failure MAX_BUFFER_CHARS was added to close (#625), reached through
|
|
46
|
+
// the door beside the one it guards (#1030).
|
|
47
|
+
//
|
|
48
|
+
// Who gets there without trying: `--history` accepts any path. The 40 events/s
|
|
49
|
+
// of 1 MB that is trivial on NVMe is roughly 30 MB/s of pure accumulation on a
|
|
50
|
+
// 5-10 MB/s network mount or a slow external drive, where the queue's only
|
|
51
|
+
// brake is that much slower than the traffic filling it.
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The ceiling on pending append bytes, and the ring's sibling on purpose.
|
|
55
|
+
*
|
|
56
|
+
* 128 MiB, the same number and the same three readings that pick
|
|
57
|
+
* MAX_BUFFER_CHARS in ring-bounds.mjs:
|
|
58
|
+
*
|
|
59
|
+
* - It is 26 times the largest single line ingest can produce (5,000,000
|
|
60
|
+
* characters plus the deck's envelope), so a burst of maximum-size tool
|
|
61
|
+
* responses — eight subagents each returning a big Read — is queued whole
|
|
62
|
+
* rather than shed.
|
|
63
|
+
* - It is thirteen times a completely full 2000-event ring of ordinary
|
|
64
|
+
* traffic. The mean serialized event is about 5 KB, so the ~26,000 lines
|
|
65
|
+
* this holds are far more than any honest burst produces: ordinary traffic
|
|
66
|
+
* never meets this bound at all.
|
|
67
|
+
* - Its worst case is a bounded fraction of the heap, and a bounded ADDITION
|
|
68
|
+
* to the ring's. 128 MiB of charged characters is at most 256 MiB retained
|
|
69
|
+
* — a two-byte string costs twice what it is charged, the same 2x
|
|
70
|
+
* MAX_CLIENT_BUFFER_BYTES' note describes — so the ring's 320 MiB and this
|
|
71
|
+
* together are about 576 MiB of the 2 GB heap an 8 GB laptop picks, against
|
|
72
|
+
* the 2260 MB one four-second burst reached with no bound here at all.
|
|
73
|
+
*
|
|
74
|
+
* CHARGED IN CHARACTERS, not in UTF-8 bytes, and the unit is deliberate. These
|
|
75
|
+
* lines are already serialized, so `line.length` is the size of the thing being
|
|
76
|
+
* retained and costs nothing to read; `Buffer.byteLength` would be a second
|
|
77
|
+
* full scan of a five-megabyte string on the hottest path in the process for a
|
|
78
|
+
* number that is FURTHER from the retained heap, not closer — JSON.stringify
|
|
79
|
+
* leaves non-ASCII unescaped, so a CJK line is two bytes of heap per character
|
|
80
|
+
* and three bytes of UTF-8.
|
|
81
|
+
*
|
|
82
|
+
* Deliberately NOT summed into MAX_BUFFER_CHARS, which #1030 raises as an
|
|
83
|
+
* option. The two hold different things — the ring holds parsed payloads
|
|
84
|
+
* charged by payloadChars' walk, this holds their serialization — so one
|
|
85
|
+
* counter would be adding two units; and eviction cannot free a queued line
|
|
86
|
+
* anyway, so a shared budget would answer a slow disk by collapsing the replay
|
|
87
|
+
* depth of the ring, which is the one part of this that was never broken.
|
|
88
|
+
*/
|
|
89
|
+
export const MAX_PENDING_APPEND_CHARS = 128 * 1024 * 1024;
|
|
90
|
+
|
|
91
|
+
// What has been accepted and not yet written, what has been refused because of
|
|
92
|
+
// it, and whether we are inside an episode of refusing. These move with the
|
|
93
|
+
// chain in appendLogLine and nowhere else, through takeCharge, noteRefused and
|
|
94
|
+
// releaseCharge below — the same rule `bufferedChars` and `events` keep in
|
|
95
|
+
// event-ring.mjs, and for the same reason: a total that names lines the queue
|
|
96
|
+
// no longer holds is a permanent debt against the budget.
|
|
97
|
+
let pendingLines = 0;
|
|
98
|
+
let pendingChars = 0;
|
|
99
|
+
let droppedLines = 0;
|
|
100
|
+
let droppedChars = 0;
|
|
101
|
+
let dropEpisodes = 0;
|
|
102
|
+
// Reset at the start of each episode, so the line printed when the queue
|
|
103
|
+
// drains describes THAT burst rather than the life of the process.
|
|
104
|
+
let episodeLines = 0;
|
|
105
|
+
let episodeChars = 0;
|
|
106
|
+
let shedding = false;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* What the append queue holds right now, and what it has refused.
|
|
110
|
+
*
|
|
111
|
+
* Exported for exactly the reason eventBufferStats and MAX_BUFFER_CHARS are —
|
|
112
|
+
* "a bound whose only observable failure is the process running out of memory
|
|
113
|
+
* is a bound no test can assert". With this, the bound is observable without
|
|
114
|
+
* watching a process die, and so is the loss it trades for.
|
|
115
|
+
*
|
|
116
|
+
* `droppedLines` / `droppedChars` are cumulative for the life of the process.
|
|
117
|
+
* They count lines that were never ATTEMPTED; an append that was attempted and
|
|
118
|
+
* failed is a different number, which #991 is open about and which belongs
|
|
119
|
+
* beside these rather than tangled into them.
|
|
120
|
+
*/
|
|
121
|
+
export function appendQueueStats() {
|
|
122
|
+
return { pendingLines, pendingChars, droppedLines, droppedChars, dropEpisodes };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const mb = chars => `${(chars / 1024 / 1024).toFixed(0)}MB`;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Would a line of `charged` characters be refused at the door right now?
|
|
129
|
+
*
|
|
130
|
+
* The queue always accepts a line when it is EMPTY, whatever that line
|
|
131
|
+
* weighs. Ingest admits 5,000,000 characters and a Codex rollout line read
|
|
132
|
+
* off disk has no length bound at all, so refusing an oversized line outright
|
|
133
|
+
* would mean a deck that silently never records its largest events — and the
|
|
134
|
+
* ring one file over makes the same exception for the same reason ("a single
|
|
135
|
+
* event is allowed to be larger than the entire budget"). So the true ceiling
|
|
136
|
+
* is MAX_PENDING_APPEND_CHARS plus one line, stated here rather than
|
|
137
|
+
* pretended away.
|
|
138
|
+
*/
|
|
139
|
+
export function queueIsFull(charged) {
|
|
140
|
+
return pendingLines > 0 && pendingChars + charged > MAX_PENDING_APPEND_CHARS;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One line accepted: charge it until releaseCharge gives the charge back. */
|
|
144
|
+
export function takeCharge(charged) {
|
|
145
|
+
pendingLines++;
|
|
146
|
+
pendingChars += charged;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One line refused at the door: count it, and open an episode if this is the
|
|
150
|
+
* first refusal since the queue last drained. */
|
|
151
|
+
export function noteRefused(filePath, charged) {
|
|
152
|
+
droppedLines++;
|
|
153
|
+
droppedChars += charged;
|
|
154
|
+
episodeLines++;
|
|
155
|
+
episodeChars += charged;
|
|
156
|
+
if (!shedding) {
|
|
157
|
+
shedding = true;
|
|
158
|
+
dropEpisodes++;
|
|
159
|
+
// One line, at the start of the episode. Per refusal it would be
|
|
160
|
+
// thousands of lines onto the terminal the deck paints over, which is its
|
|
161
|
+
// own version of the problem being fixed.
|
|
162
|
+
console.error(`${PRODUCT}: the log append queue is full (${mb(pendingChars)} waiting for ${filePath}) — dropping events until it drains`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* One line has left the queue. Give its charge back, and close the episode if
|
|
168
|
+
* that was the last of them.
|
|
169
|
+
*
|
|
170
|
+
* The episode ends when the queue is EMPTY rather than the moment it dips back
|
|
171
|
+
* under the bound, because it dips under the bound once per completed write:
|
|
172
|
+
* keyed on the bound this would print a pair of lines per event for the length
|
|
173
|
+
* of the burst. Empty is also the honest boundary for the number being
|
|
174
|
+
* reported — while anything is still queued the next line can still be refused,
|
|
175
|
+
* and the total would have to be retracted.
|
|
176
|
+
*/
|
|
177
|
+
export function releaseCharge(charged) {
|
|
178
|
+
pendingLines--;
|
|
179
|
+
pendingChars -= charged;
|
|
180
|
+
if (pendingLines > 0 || !shedding) return;
|
|
181
|
+
console.error(`${PRODUCT}: the log append queue drained — ${episodeLines} event(s) (${mb(episodeChars)}) were dropped and are not in the log`);
|
|
182
|
+
shedding = false;
|
|
183
|
+
episodeLines = 0;
|
|
184
|
+
episodeChars = 0;
|
|
185
|
+
}
|