actual-mcp-server 0.16.5 → 0.16.7

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 CHANGED
@@ -476,6 +476,7 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
476
476
  | `ACTUAL_BUDGET_PASSWORD` | _(none)_ | No | Optional encryption password for encrypted budgets |
477
477
  | `ALLOW_INSECURE_UPSTREAM` | `false` | No | Allow an `http://` upstream even when `ACTUAL_BUDGET_PASSWORD` is set (#161). Off by default so a plaintext upstream plus an encryption password is refused |
478
478
  | `ACTUAL_OP_TIMEOUT_MS` | `30000` | No | Per-operation timeout (ms) for every upstream Actual API call (init, budget download, sync, and each tool operation). A stalled call rejects after this bound so it cannot hold the global API mutex forever and hang subsequent tool calls (#270). Set to `0` to disable |
479
+ | `ACTUAL_IMPORT_TIMEOUT_MS` | `600000` | No | Separate, larger timeout (ms) for a budget import, which is legitimately long rather than stalled. An import is a tracked load, so every other session waits on it; bounding it at the general operation timeout turned one tenant's large import into a process-wide stall (#407). Set to `0` to disable |
479
480
  | **MCP Server Settings** ||||
480
481
  | `MCP_BRIDGE_PORT` | `3600` | No | Port for MCP server to listen on |
481
482
  | `MCP_BRIDGE_BIND_HOST` | `0.0.0.0` | No | Host address to bind server to (`0.0.0.0` = all interfaces) |
@@ -774,7 +775,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
774
775
 
775
776
  ---
776
777
 
777
- **Version:** 0.16.5 | **Tool Count:** 74 (verified LibreChat-compatible)
778
+ **Version:** 0.16.7 | **Tool Count:** 74 (verified LibreChat-compatible)
778
779
 
779
780
  ## Sponsor
780
781
 
@@ -30,6 +30,34 @@ export const configSchema = z.object({
30
30
  return 0;
31
31
  return Math.min(Math.max(n, 250), 2147483647);
32
32
  }),
33
+ /**
34
+ * #407: a separate, larger bound for a budget IMPORT.
35
+ *
36
+ * An import is not a stalled operation, it is a legitimately long one: a large YNAB or zip
37
+ * import runs for minutes. #394 made it a TRACKED load, so while it is in flight every other
38
+ * session's lock acquisition waits for it and then fails at ACTUAL_OP_TIMEOUT_MS. Bounding a
39
+ * five-minute import at the 30 second operation default therefore turned one tenant's import
40
+ * into a process-wide stall, with every other session failing repeatedly for the duration.
41
+ *
42
+ * Same parse-and-clamp shape as ACTUAL_OP_TIMEOUT_MS, and the same escape hatch: 0 disables the
43
+ * bound, which for an import means it can hold the process-global api mutex indefinitely (the
44
+ * #393 P0 shape), so it is for diagnosis rather than production.
45
+ *
46
+ * The clamp floor is 250ms, the same as the operation timeout. It is deliberately NOT raised to
47
+ * the operation timeout's configured VALUE: an earlier comment here claimed an import bound below
48
+ * the general one was prevented, and it was not, so the claim is removed rather than left to
49
+ * mislead. A smaller import bound is unusual but coherent (abandon imports sooner than ordinary
50
+ * reads), and enforcing an ordering between two independently configured knobs at parse time
51
+ * would need one to be parsed before the other.
52
+ */
53
+ ACTUAL_IMPORT_TIMEOUT_MS: z.string().default('600000').transform(val => {
54
+ const n = parseInt(val, 10);
55
+ if (!Number.isFinite(n) || n < 0)
56
+ return 600000;
57
+ if (n === 0)
58
+ return 0;
59
+ return Math.min(Math.max(n, 250), 2147483647);
60
+ }),
33
61
  // Escape hatch for #161: allow an http:// upstream even when an E2E encryption
34
62
  // password is set (e.g. an isolated Docker network where the hop is trusted).
35
63
  // Off by default so a plaintext upstream + encryption password is refused.
@@ -386,7 +386,65 @@ class ActualConnectionPool {
386
386
  * the connection: in those cases the transport must survive.
387
387
  */
388
388
  async shutdownConnection(sessionId, opts = {}) {
389
+ // #392: acquire the api mutex, because this calls api.shutdown() on the PROCESS-GLOBAL
390
+ // singleton. Without it, the idle sweep or an explicit session close can tear the api down
391
+ // while ANOTHER session's operation is in flight inside the lock, and that operation then
392
+ // observes a torn-down api. Reproduced during #390's security review for the sibling cleanup
393
+ // site, which #390 fixed; these were the paths it left behind.
394
+ //
395
+ // NOT reentrant, so this variant is for callers that do NOT hold the lock. Callers that DO
396
+ // must call shutdownConnectionLocked directly; wrapping there would deadlock, and the symptom
397
+ // is a ~30s stall ended by #270's timeout rather than an obvious hang. The audit of which is
398
+ // which is recorded on shutdownConnectionLocked.
399
+ // #392 review (BLOCKING): the acquisition itself can REJECT. Since #393, taking the api lock
400
+ // settles any abandoned budget load and THROWS on timeout, so this wrapper gained a rejection
401
+ // path the old body never had (it was try/catch/finally end to end). Its callers are cleanup
402
+ // paths that do not expect one: `cleanupIdleConnections` is invoked from `setInterval`
403
+ // unawaited, and "abandoned budget load timed out" is not in `rejection-allowlist.ts`, so the
404
+ // escape reached `src/index.ts`'s unhandledRejection handler and called process.exit(1). That
405
+ // is the exact defect #393 fixed one level up in the write drain, reintroduced at a new
406
+ // acquisition site.
407
+ //
408
+ // So the acquisition is swallowed, matching the established pattern in the session-open
409
+ // cleanup above: cleanup must not fail louder than the thing it is cleaning up after.
410
+ // The entry is still torn down on that path, unless the caller asked for expiry to be
411
+ // re-checked and the session came back, which is the sweep's own contract. Tearing down
412
+ // matters because a stuck load must not make idle sessions un-evictable: the pool would fill
413
+ // to MAX_CONCURRENT_SESSIONS and start refusing new sessions with nothing in the log saying
414
+ // why. The unlocked shutdown in this handler is acceptable for a specific reason worth
415
+ // stating: while a registration is stuck, EVERY lock acquisition throws, so no operation can
416
+ // be running inside the lock at the moment this path fires.
417
+ await withApiLock(() => this.shutdownConnectionLocked(sessionId, opts)).catch(async (err) => {
418
+ logger.error(`[ConnectionPool] Could not acquire the api lock to shut down ${sessionId}; ` +
419
+ `tearing the entry down without it: ${err instanceof Error ? err.message : String(err)}`);
420
+ await this.shutdownConnectionLocked(sessionId, opts).catch(() => { });
421
+ });
422
+ }
423
+ /**
424
+ * The body of shutdownConnection, for callers that ALREADY hold the api mutex.
425
+ *
426
+ * #392 call-site audit, done explicitly rather than by inspection because the mutex is not
427
+ * reentrant and getting it wrong produces a stall that reads as a slow upstream server:
428
+ *
429
+ * HOLDS the lock, so calls this variant:
430
+ * - actual-adapter withActualApi pooled branch, infra-error drop
431
+ * - actual-adapter withActualApiWrite, sync failure and infra-error drop
432
+ * - actual-adapter processWriteQueue drain, sync failure and batch error
433
+ * Does NOT hold it, so calls the wrapping shutdownConnection:
434
+ * - actual-adapter switchBudget slow path (its own withApiLock block has closed by then)
435
+ * - actualConnection.shutdownActualForSession (session close)
436
+ * - this.cleanupIdleConnections (the idle sweep)
437
+ * - this.shutdownAll
438
+ */
439
+ async shutdownConnectionLocked(sessionId, opts = {}) {
389
440
  const conn = this.connections.get(sessionId);
441
+ // #392 review: the idle sweep decides who is expired BEFORE waiting for the mutex, and that
442
+ // wait is no longer negligible. Re-checking here means a session that was touched while we
443
+ // queued keeps its connection instead of being closed mid-conversation.
444
+ if (opts.onlyIfExpired && conn && !this.isExpired(conn)) {
445
+ logger.debug(`[ConnectionPool] ${sessionId} was touched while waiting for the api lock; not evicting`);
446
+ return;
447
+ }
390
448
  if (!conn || !conn.initialized) {
391
449
  return;
392
450
  }
@@ -424,6 +482,16 @@ class ActualConnectionPool {
424
482
  * Shutdown the shared connection
425
483
  */
426
484
  async shutdownSharedConnection() {
485
+ // #392: same reasoning as shutdownConnection. Its only caller is shutdownAll, which does not
486
+ // hold the mutex.
487
+ await withApiLock(() => this.shutdownSharedConnectionLocked()).catch(async (err) => {
488
+ logger.error(`[ConnectionPool] Could not acquire the api lock to shut down the shared connection; ` +
489
+ `tearing it down without it: ${err instanceof Error ? err.message : String(err)}`);
490
+ await this.shutdownSharedConnectionLocked().catch(() => { });
491
+ });
492
+ }
493
+ /** The body of shutdownSharedConnection, for callers that already hold the api mutex. */
494
+ async shutdownSharedConnectionLocked() {
427
495
  if (!this.sharedConnection?.initialized) {
428
496
  return;
429
497
  }
@@ -500,7 +568,15 @@ class ActualConnectionPool {
500
568
  // in the same window (#167). This is the path that previously drifted:
501
569
  // the pool's autonomous timer removed an entry httpServer's separate
502
570
  // timer knew nothing about.
503
- await this.shutdownConnection(sessionId, { evict: true });
571
+ //
572
+ // #392 review: RE-CHECK expiry, because the decision above was taken before the lock and
573
+ // the eviction now waits for it. Before #392 that wait was effectively zero; since #378
574
+ // made drains sequential, one lock hold is the SUM of a batch's operations, so it is
575
+ // bounded only by batch length times ACTUAL_OP_TIMEOUT_MS. A session that was idle when
576
+ // marked can be active again by the time the lock arrives, and evicting it then closes
577
+ // its transport mid-conversation. Deciding and acting on the same side of the mutex is
578
+ // what makes the decision mean anything.
579
+ await this.shutdownConnection(sessionId, { evict: true, onlyIfExpired: true });
504
580
  }
505
581
  }
506
582
  }
@@ -21,9 +21,9 @@ import { parseBudgetRegistry } from './budget-registry.js';
21
21
  import { getPreferredBudgetSyncId, setPreferredBudgetSyncId, pickAllowedPreferredBudget } from './budget-preference-store.js';
22
22
  import { requestContext } from './requestContext.js';
23
23
  import { connectionPool } from './ActualConnectionPool.js';
24
- import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, setLoadedBudgetSyncId } from './apiState.js';
24
+ import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, awaitAbandonedBudgetLoad } from './apiState.js';
25
25
  import { withApiLock } from './apiLock.js';
26
- import { loadBudgetTracked } from './budgetLoader.js';
26
+ import { loadBudgetTracked, importBudgetTracked } from './budgetLoader.js';
27
27
  /**
28
28
  * Budget registry — all budgets configured via ACTUAL_* and BUDGET_n_* env vars.
29
29
  * Built once at startup; used by every withActualApi call.
@@ -347,8 +347,9 @@ export async function withActualApi(rawOperation) {
347
347
  // re-introduce the auth-burst pattern #134 is fixing.
348
348
  if (_shouldDropPoolOnError(err)) {
349
349
  logger.warn(`[ADAPTER] Releasing pool connection for session ${sessionId} after infrastructure-level error`);
350
+ // #392: Locked variant. We are INSIDE withApiLock here; the wrapping one would deadlock.
350
351
  try {
351
- await connectionPool.shutdownConnection(sessionId);
352
+ await connectionPool.shutdownConnectionLocked(sessionId);
352
353
  }
353
354
  catch (_e) { /* swallow */ }
354
355
  }
@@ -407,8 +408,9 @@ export async function withActualApiWrite(operation) {
407
408
  // Sync failure on a write IS infrastructure-level — drop the pool
408
409
  // connection so the next call re-inits, then surface the error.
409
410
  logger.error(`[ADAPTER] api.sync() failed after write in session ${sessionId}; releasing pool connection`);
411
+ // #392: Locked variant. We are INSIDE withApiLock here; the wrapping one would deadlock.
410
412
  try {
411
- await connectionPool.shutdownConnection(sessionId);
413
+ await connectionPool.shutdownConnectionLocked(sessionId);
412
414
  }
413
415
  catch (_e) { /* swallow */ }
414
416
  throw syncErr;
@@ -421,8 +423,9 @@ export async function withActualApiWrite(operation) {
421
423
  // leave the connection fine.
422
424
  if (_shouldDropPoolOnError(err)) {
423
425
  logger.warn(`[ADAPTER] Releasing pool connection for write session ${sessionId} after infrastructure-level error`);
426
+ // #392: Locked variant. We are INSIDE withApiLock here; the wrapping one would deadlock.
424
427
  try {
425
- await connectionPool.shutdownConnection(sessionId);
428
+ await connectionPool.shutdownConnectionLocked(sessionId);
426
429
  }
427
430
  catch (_e) { /* swallow */ }
428
431
  }
@@ -827,7 +830,7 @@ async function processWriteQueue() {
827
830
  //
828
831
  // Each op keeps its own withOpTimeout (#270) and still settles individually, so one
829
832
  // stalled or rejected operation cannot hold the api mutex or fail its siblings.
830
- for (const { operation, resolve, reject, preservesListings, requestStore } of batch) {
833
+ for (const { operation, resolve, reject, preservesListings, requestStore, timeoutMs } of batch) {
831
834
  try {
832
835
  // #390 round 2: run each op in the context it was ENQUEUED in.
833
836
  //
@@ -844,8 +847,40 @@ async function processWriteQueue() {
844
847
  // been correct wrong. Re-entering the captured store makes each op resolve its own
845
848
  // budget, which is what #158 captured a per-entry sessionId for in the first place.
846
849
  const result = await requestContext.run(requestStore ?? {}, async () => {
850
+ // #406: settle any abandoned budget load PER OPERATION, not per lock acquisition.
851
+ //
852
+ // #393 made the wait part of acquiring the api lock, which makes the set of call
853
+ // sites stop mattering: nothing reaches the api without the lock, so nothing reaches
854
+ // it without the wait. That holds for every path where one acquisition serves one
855
+ // operation. It does NOT hold here. This drain acquires the lock ONCE and then runs
856
+ // N operations inside it, so once any op abandons a load, every LATER op in the same
857
+ // batch runs against a singleton that abandoned load will re-point, having never
858
+ // waited for it. Reproduced with a slow import and a following write in one debounce
859
+ // window; it is the #390 class with a new author.
860
+ //
861
+ // Deliberately NOT inside ensureLoadedBudgetMatchesSession: that returns early when
862
+ // the loaded budget already matches, and an op whose budget happens to match still
863
+ // needs the wait, because the abandoned load is about to move the singleton AWAY
864
+ // from it. The wait has to be unconditional, so it sits before the precondition.
865
+ //
866
+ // A rejection here is caught by this loop's own per-op catch, so a stuck load fails
867
+ // ONE operation rather than the whole batch, which is the behaviour #393 round 4
868
+ // established for the drain.
869
+ await awaitAbandonedBudgetLoad(withOpTimeout);
847
870
  await ensureLoadedBudgetMatchesSession();
848
- return await withOpTimeout(operation);
871
+ // #407: an operation may carry its OWN bound. Only a budget import does today, and
872
+ // it needs one: the import is legitimately long, and bounding it at the general
873
+ // operation timeout abandons it while every other session is already waiting on it
874
+ // as a tracked load. Without this the raised bound inside the loader is dead letter,
875
+ // because THIS wrapper would abandon the op first.
876
+ //
877
+ // A long op does NOT need a keep-alive heartbeat, and one was written and removed in
878
+ // review. The session cannot be swept out from under it: `cleanupIdleConnections`
879
+ // passes `onlyIfExpired`, so `shutdownConnectionLocked` re-checks expiry AFTER
880
+ // acquiring the mutex (#392), and the `finally` below touches every session in the
881
+ // batch before the lock is released. A sweep that queued behind this drain therefore
882
+ // finds the entry fresh and returns without evicting.
883
+ return await withOpTimeout(operation, 'operation', timeoutMs);
849
884
  });
850
885
  resolve(result);
851
886
  }
@@ -871,10 +906,12 @@ async function processWriteQueue() {
871
906
  // the batch ran concurrently and a drain was bounded by roughly one
872
907
  // ACTUAL_OP_TIMEOUT_MS. Sequential dispatch makes a drain's wall clock the SUM of
873
908
  // its ops, so a batch of slow ops can now outlive SESSION_IDLE_TIMEOUT_MINUTES
874
- // (default 5) and be swept mid-drain. The sweep's shutdownConnection calls
875
- // api.shutdown() WITHOUT holding withApiLock, which is the same hazard this file
876
- // already documents as the reason stdio is kept off the pool: it tears the
877
- // singleton down underneath an in-flight operation.
909
+ // (default 5) and be swept mid-drain. The sweep would then evict the session and
910
+ // fire its eviction listeners, closing the transport under a drain that is still
911
+ // running. Since #392 the sweep takes the api mutex and re-checks expiry, so it can
912
+ // no longer tear the singleton down mid-operation, but an unrefreshed entry is still
913
+ // MARKED expired, so the touch below is what keeps an active session out of the
914
+ // sweep's list at all.
878
915
  //
879
916
  // THIS MUST STAY INSIDE THE LOOP. A single touch at drain start does nothing for
880
917
  // a drain whose wall clock is the SUM of its ops, which is the entire reason the
@@ -909,7 +946,9 @@ async function processWriteQueue() {
909
946
  if (usePoolBranch && _shouldDropPoolOnError(syncError)) {
910
947
  logger.warn(`[WRITE QUEUE] Releasing pool connection for session ${batchSessionId} after sync failure`);
911
948
  try {
912
- await connectionPool.shutdownConnection(batchSessionId);
949
+ // #392: Locked variant; the drain holds withApiLock across this whole block, and
950
+ // the mutex is not reentrant, so the wrapping variant would deadlock here.
951
+ await connectionPool.shutdownConnectionLocked(batchSessionId);
913
952
  }
914
953
  catch (_e) {
915
954
  /* swallow */
@@ -942,7 +981,9 @@ async function processWriteQueue() {
942
981
  if (usePoolBranch) {
943
982
  if (_shouldDropPoolOnError(error)) {
944
983
  try {
945
- await connectionPool.shutdownConnection(batchSessionId);
984
+ // #392: Locked variant; the drain holds withApiLock across this whole block, and
985
+ // the mutex is not reentrant, so the wrapping variant would deadlock here.
986
+ await connectionPool.shutdownConnectionLocked(batchSessionId);
946
987
  }
947
988
  catch (_e) {
948
989
  /* swallow */
@@ -1012,6 +1053,7 @@ function queueWriteOperation(operation, options) {
1012
1053
  sessionId,
1013
1054
  requestStore: requestContext.getStore(),
1014
1055
  preservesListings: options?.preservesListings,
1056
+ timeoutMs: options?.timeoutMs,
1015
1057
  };
1016
1058
  // #278: bound queue RESIDENCY, not just execution. #270's withOpTimeout bounds an
1017
1059
  // operation that is RUNNING; it cannot bound one that never starts. Reuse the same
@@ -2684,8 +2726,9 @@ export async function switchBudget(name) {
2684
2726
  // init/shutdown cycle. A pooled entry for stdio would be created here and then
2685
2727
  // NEVER refreshed, because connectionPool.touch() is called only from
2686
2728
  // httpServer.ts; it would expire after SESSION_IDLE_TIMEOUT_MINUTES and
2687
- // cleanupIdleConnections would api.shutdown() it WITHOUT holding withApiLock,
2688
- // which can tear the singleton down underneath an in-flight operation. The
2729
+ // cleanupIdleConnections would tear it down. Since #392 that teardown takes the api mutex and
2730
+ // re-checks expiry, so it is no longer a mid-operation hazard, but an entry that is never
2731
+ // refreshed still expires, and that is the reason that matters here. The
2689
2732
  // legacy path re-reads getActiveBudgetConfig() on every call, so the switch
2690
2733
  // still takes effect; it just costs an init per operation, which is what stdio
2691
2734
  // already did before this ticket.
@@ -2874,22 +2917,46 @@ export async function exportBudget() {
2874
2917
  export async function importBudget(input, opts = {}) {
2875
2918
  observability.incrementToolCall('actual.budgets.import').catch(() => { });
2876
2919
  const result = await queueWriteOperation(async () => {
2877
- const imported = await withConcurrency(() => retry(() => rawImportBudget(input, opts), {
2878
- retries: 0,
2879
- backoffMs: 200,
2880
- isRetryable: isRetryableError,
2881
- }));
2882
- // #390: an import CHANGES the loaded budget (upstream documents importBudget as "loads the
2883
- // imported budget"), and the singleton's record kept naming the pre-import one. That is the
2884
- // unsafe direction: the precondition compares against it, passes, and a session then reads
2885
- // the imported file while believing it is on its own budget. A sentinel rather than a real
2886
- // syncId, because an imported file is outside the budget registry and outside the ACL, so
2887
- // no session can ever legitimately match it and every session's next operation re-selects
2888
- // its own budget. Recorded AT the call site so the guard in
2889
- // tests/unit/budget_selection_precondition.test.js can see it.
2890
- setLoadedBudgetSyncId(`imported:${imported.id}`);
2920
+ // #394: an import re-points the process-global singleton exactly as a download does, so it
2921
+ // gets the SAME tracking discipline, through the same helper, rather than a hand-written
2922
+ // approximation of it.
2923
+ //
2924
+ // What was wrong before: nothing cleared the record before the import started, nothing
2925
+ // registered the promise, and the sentinel was written AFTER the await, so a timeout skipped
2926
+ // it entirely. A large YNAB or zip import exceeding ACTUAL_OP_TIMEOUT_MS is the expected case,
2927
+ // not a rare one. The record therefore went on naming the PRE-IMPORT budget while the
2928
+ // singleton moved to an out-of-registry, un-ACL'd imported file, and a victim session passed
2929
+ // the #390 precondition legitimately and had its reads served from the importer's file.
2930
+ //
2931
+ // #390 still holds at this call site: `importBudgetTracked` records the `imported:` sentinel
2932
+ // when the promise settles, whichever way, and the clear below states the same fact at the
2933
+ // call site where the guard in tests/unit/budget_selection_precondition.test.js can see it.
2934
+ // #408: invalidate BEFORE the import starts, not after it resolves.
2935
+ //
2936
+ // The invalidation below used to run only after `queueWriteOperation` resolved, so on the
2937
+ // TIMEOUT path it never ran at all, and a large import exceeding ACTUAL_OP_TIMEOUT_MS is the
2938
+ // expected case rather than a rare one. Every pool entry then kept naming the pre-import
2939
+ // syncId while the singleton moved to the imported file, and switchBudget's #172 fast path
2940
+ // no-opped on a switch back: the reported-success-with-no-effect class #349 exists to prevent.
2941
+ //
2942
+ // Moving it here follows the same clear-before-you-start discipline #394 established for the
2943
+ // record itself: a mutation that may be abandoned must leave state INDETERMINATE, never
2944
+ // confidently wrong. It is idempotent and costs nothing on the success path, where the call
2945
+ // after the write simply re-applies the same sentinel.
2946
+ const preInvalidated = connectionPool.invalidateAllLoadedSyncIds('imported:pending');
2947
+ if (preInvalidated > 0) {
2948
+ logger.debug(`[ADAPTER] importBudget: invalidated the pooled syncId on ${preInvalidated} session(s) before starting`);
2949
+ }
2950
+ const imported = await importBudgetTracked(() => {
2951
+ const started = withConcurrency(() => retry(() => rawImportBudget(input, opts), {
2952
+ retries: 0,
2953
+ backoffMs: 200,
2954
+ isRetryable: isRetryableError,
2955
+ }));
2956
+ return started;
2957
+ });
2891
2958
  return imported;
2892
- });
2959
+ }, { timeoutMs: config.ACTUAL_IMPORT_TIMEOUT_MS });
2893
2960
  // #349: an import CHANGES WHICH BUDGET IS LOADED, so the pool's record of it
2894
2961
  // must stop naming the old one.
2895
2962
  //
@@ -131,3 +131,17 @@ export async function awaitAbandonedBudgetLoad(bound) {
131
131
  export function _hasPendingBudgetLoadForTests() {
132
132
  return _pendingBudgetLoads.size > 0;
133
133
  }
134
+ /**
135
+ * Test hook: drop every registration.
136
+ *
137
+ * There is deliberately no production unregister: an entry leaves this set only when its promise
138
+ * settles, which is what makes "is a load outstanding" mean anything (#393). But a test that
139
+ * registers a NEVER-settling load has no way back, and leaving one pending poisons every later
140
+ * lock acquisition in that file, so a later case silently exercises a poisoned lock while claiming
141
+ * to test something else. Both of this repo's abandoned-load test files hit exactly that.
142
+ *
143
+ * Test-only, and not exported through the package surface.
144
+ */
145
+ export function _clearPendingBudgetLoadsForTests() {
146
+ _pendingBudgetLoads.clear();
147
+ }
@@ -1,5 +1,6 @@
1
1
  import api from '@actual-app/api';
2
2
  import { withOpTimeout } from './opTimeout.js';
3
+ import config from '../config.js';
3
4
  import { setApiInitialized, setLoadedBudgetSyncId, registerBudgetLoad } from './apiState.js';
4
5
  import { createModuleLogger } from './loggerFactory.js';
5
6
  const log = createModuleLogger('BUDGET-LOADER');
@@ -154,6 +155,23 @@ function scrubReason(prose) {
154
155
  * so putting two network calls in there would extend the window in which unrelated sessions' lock
155
156
  * acquisitions throw, to build a message an abandoned load has no caller to read.
156
157
  */
158
+ /**
159
+ * #404: the cached reason is re-derived every REDIAGNOSE_AFTER_USES failures.
160
+ *
161
+ * The cache exists because `diagnose()` runs INSIDE the process-global api mutex and makes two
162
+ * bounded network calls, so re-running it per failed tool call stalls unrelated sessions. But an
163
+ * entry was previously dropped only by a genuinely successful load or a process restart, so if a
164
+ * budget's failure MODE changed with no success in between, the message reported the first reason
165
+ * indefinitely.
166
+ *
167
+ * Bounding by USES rather than by wall-clock is deliberate: the cost this cache avoids is paid per
168
+ * CALL, so capping in calls bounds staleness in the same unit, needs no clock, and is
169
+ * deterministic under test. At 20, a persistent failure pays the diagnosis roughly 5 percent of
170
+ * the time (the saving the cache exists for is kept) while no operator can see a stale reason for
171
+ * more than 20 failed calls. The fail-closed DECISION is still never cached: every call
172
+ * re-attempts the load and still fails.
173
+ */
174
+ const REDIAGNOSE_AFTER_USES = 20;
157
175
  const reasonCache = new Map();
158
176
  /** A load that genuinely succeeded clears any cached reason for that budget. */
159
177
  function clearCachedReason(syncId) {
@@ -164,6 +182,9 @@ export function _clearReasonCacheForTests() {
164
182
  reasonCache.clear();
165
183
  }
166
184
  async function diagnose(syncId) {
185
+ // #409: what the cache held before a re-diagnosis, so a failed re-diagnosis can fall back to it
186
+ // rather than reporting nothing.
187
+ let staleReason;
167
188
  // #396 review: memoise the reason STRING, never the fail-closed decision. Without this, every
168
189
  // tool call while the condition persists pays two bounded network calls WHILE HOLDING the
169
190
  // process-global api mutex, so a single unloadable budget stalls unrelated sessions for up to
@@ -177,8 +198,25 @@ async function diagnose(syncId) {
177
198
  // precisely the cost this memo exists to remove.
178
199
  const cached = reasonCache.get(syncId);
179
200
  if (cached !== undefined) {
180
- log.debug('reusing the cached load-failure reason', { syncId });
181
- return cached || undefined;
201
+ cached.uses += 1;
202
+ if (cached.uses <= REDIAGNOSE_AFTER_USES) {
203
+ log.debug('reusing the cached load-failure reason', { syncId, uses: cached.uses });
204
+ return cached.reason || undefined;
205
+ }
206
+ // #404: the entry has served its budget of calls, so fall through to a fresh diagnosis and
207
+ // pick up a failure mode that changed without an intervening success.
208
+ //
209
+ // #409: do NOT delete it here. Deleting first meant a re-diagnosis that then failed
210
+ // transiently (a `getBudgets` blip) left the caller with "upstream discarded the reason"
211
+ // despite a perfectly good cached one having existed a moment earlier, and the next call paid
212
+ // two more bounded network calls inside the api mutex to rediscover it. The stale entry is
213
+ // kept as a fallback and is only overwritten when a new answer is actually produced.
214
+ staleReason = cached.reason;
215
+ cached.uses = 0;
216
+ log.debug('re-diagnosing the load failure after the cached reason served its budget', {
217
+ syncId,
218
+ afterUses: REDIAGNOSE_AFTER_USES,
219
+ });
182
220
  }
183
221
  try {
184
222
  const budgets = (await withOpTimeout(() => api.getBudgets(), 'diagnose getBudgets'));
@@ -190,7 +228,7 @@ async function diagnose(syncId) {
190
228
  if (!local) {
191
229
  // Deliberately does NOT list what IS available: that would enumerate every budget on the
192
230
  // server to a caller the ACL scopes to one.
193
- reasonCache.set(syncId, NO_LOCAL_COPY);
231
+ reasonCache.set(syncId, { reason: NO_LOCAL_COPY, uses: 0 });
194
232
  return NO_LOCAL_COPY;
195
233
  }
196
234
  const localId = local.id;
@@ -208,6 +246,13 @@ async function diagnose(syncId) {
208
246
  // stay in THIS brace-balanced block: the guard's window ends at the first `}` that takes
209
247
  // depth below zero, so a setter in a sibling `catch` or `finally` arm would be outside it.
210
248
  setLoadedBudgetSyncId(null);
249
+ // #407 review (I3): DROP any cached reason here. #409 stopped deleting the entry before a
250
+ // re-diagnosis, which is right for the failed-diagnosis path, but on THIS path the old
251
+ // reason has just been proven wrong: the load worked. Leaving it cached with uses reset
252
+ // meant the next 20 calls reported a permanent-sounding migration code for a condition the
253
+ // diagnosis had just shown to be transient, which is exactly the reading the comment below
254
+ // says to avoid.
255
+ reasonCache.delete(syncId);
211
256
  // #396 review: the retry SUCCEEDED, so this was not a deterministic unloadable budget. Say
212
257
  // so rather than reporting "upstream discarded the reason", which reads as permanent. The
213
258
  // operation still fails (the record is indeterminate by design, which forces a re-select),
@@ -228,13 +273,20 @@ async function diagnose(syncId) {
228
273
  // `.code` outside KNOWN_LOAD_REASONS and path-shaped prose that `scrubReason` drops, so it
229
274
  // yields `undefined`, and that is the ticket's own reproduced "db.sqlite is not a database"
230
275
  // case: the one most likely to persist. Storing '' records "diagnosed" without reporting.
231
- reasonCache.set(syncId, reason ?? '');
276
+ reasonCache.set(syncId, { reason: reason ?? '', uses: 0 });
232
277
  return reason;
233
278
  }
234
279
  }
235
280
  catch (diagErr) {
236
281
  // A failing diagnostic is LOGGED, never substituted for the post-condition error.
237
282
  log.warn('could not diagnose why the budget did not load', { syncId, error: messageOf(diagErr) });
283
+ // #409: fall back to the previous answer rather than reporting nothing. It described this same
284
+ // budget's failure moments ago and is far more useful than silence; the use counter was reset,
285
+ // so the next call re-attempts the diagnosis anyway.
286
+ if (staleReason !== undefined) {
287
+ reasonCache.set(syncId, { reason: staleReason, uses: 0 });
288
+ return staleReason || undefined;
289
+ }
238
290
  return undefined;
239
291
  }
240
292
  }
@@ -247,45 +299,126 @@ function postConditionError(syncId, reason) {
247
299
  `this), and a local budget copy that cannot be opened (check the directory configured by ` +
248
300
  `MCP_BRIDGE_DATA_DIR, and note that two server processes sharing one data dir will do this).`);
249
301
  }
250
- export async function loadBudgetTracked(syncId, encryptionPassword, label = 'downloadBudget') {
251
- const raw = encryptionPassword
252
- ? api
253
- .downloadBudget(syncId, { password: encryptionPassword })
254
- : api.downloadBudget(syncId);
255
- // Indeterminate from here until it settles. Cleared first, deliberately.
302
+ /**
303
+ * The tracking discipline EVERY singleton-mutating load must follow (#390, #393, #394).
304
+ *
305
+ * Extracted so the two callers cannot drift. `downloadBudget` and `importBudget` are different
306
+ * upstream calls with different failure shapes, but the discipline is identical and getting it
307
+ * subtly different at one site is exactly how #390 stayed reachable for three rounds:
308
+ *
309
+ * 1. Clear the record BEFORE starting, so an abandonment can only leave it INDETERMINATE.
310
+ * That is the safe direction: it forces a re-select. Recording only on success leaves the
311
+ * record naming the PREVIOUS budget while the singleton moves to a new one, which is the one
312
+ * direction that makes the #390 precondition silently pass.
313
+ * 2. Register the promise, so a late landing is WAITED FOR by the next lock acquisition rather
314
+ * than discovered mid-operation. `withOpTimeout` races; it does not cancel.
315
+ * 3. Record the true outcome when it settles, whether or not anyone is still listening, and
316
+ * poison the singleton on failure, because upstream closes the current budget before opening
317
+ * the new one, so "failed" does not mean "unchanged".
318
+ *
319
+ * `verify` is #396's post-condition, and it runs BEFORE the record is written, so the record can
320
+ * only ever describe a mutation that actually took effect.
321
+ */
322
+ async function trackBudgetMutation(start, describeLoaded, label, verify, onRecorded, timeoutMs) {
323
+ // Indeterminate from here until it settles. Cleared BEFORE the load is even started, so the
324
+ // record can never name the outgoing budget while the singleton is moving away from it: that is
325
+ // the one direction that makes #390's precondition silently pass. It used to be cleared on the
326
+ // statement AFTER `start()`, which was safe in practice (both run in one synchronous turn, so
327
+ // nothing could observe the gap) but made the invariant untestable and the comment's own word
328
+ // "first" untrue.
256
329
  setLoadedBudgetSyncId(null);
257
- // #396: `.then(onFulfilled).catch(onRejected)`, NOT the two-argument `.then`. A throw from the
258
- // `onFulfilled` argument of `.then(onFulfilled, onRejected)` is NOT caught by that same
259
- // `onRejected`, so with the old shape a probe failure would skip the fail-closed
260
- // `setApiInitialized(false)` below: exactly the new failure path this adds.
261
- //
262
- // The probe lives INSIDE this chain so an ABANDONED load is verified when it lands, rather than
263
- // recording a false success nobody checked. The network enrichment deliberately does not.
330
+ const raw = start();
264
331
  const tracked = raw
265
- .then(async () => {
266
- await assertBudgetOpen();
267
- setLoadedBudgetSyncId(syncId);
268
- clearCachedReason(syncId);
332
+ .then(async (result) => {
333
+ if (verify)
334
+ await verify();
335
+ setLoadedBudgetSyncId(describeLoaded(result));
336
+ // #392 review (finding 5): this runs INSIDE the chain, not after the awaited call. On the
337
+ // abandoned-then-eventually-successful path the caller has already thrown, so a hook placed
338
+ // after the await would never run and the cached failure reason would outlive the load that
339
+ // actually succeeded.
340
+ // Wrapped: a hook cannot be allowed to change the recorded outcome. Unwrapped, a throwing
341
+ // hook would be caught by the trailing .catch, reported as a load failure, and would leave
342
+ // the record written while setApiInitialized(false) says the singleton is dead: an
343
+ // incoherent pair rather than a fail-safe one.
344
+ try {
345
+ onRecorded?.();
346
+ }
347
+ catch (hookErr) {
348
+ log.warn('post-record hook threw; the recorded outcome stands', { error: String(hookErr) });
349
+ }
350
+ return result;
269
351
  })
270
352
  .catch((err) => {
271
353
  // A failed load leaves the singleton in a state nobody can describe: upstream's
272
- // download handler closes the current budget before opening the new one, so "failed"
273
- // does not mean "unchanged". Poison it so the next operation re-inits from scratch.
354
+ // download and import handlers both close the current budget before opening the new one,
355
+ // so "failed" does not mean "unchanged". Poison it so the next operation re-inits.
274
356
  setApiInitialized(false);
275
357
  throw err;
276
358
  });
277
359
  registerBudgetLoad(tracked);
360
+ // On timeout the tracked promise is still running. Leave it REGISTERED: the next operation
361
+ // waits for it inside the lock rather than racing it.
362
+ return await withOpTimeout(() => tracked, label, timeoutMs);
363
+ }
364
+ /**
365
+ * #394: the same discipline for an IMPORT.
366
+ *
367
+ * Upstream's `importBudget` LOADS the imported budget, so it re-points the process-global
368
+ * singleton exactly as a download does, and a large YNAB or zip import exceeding
369
+ * ACTUAL_OP_TIMEOUT_MS is the expected case rather than a rare one. Before this, nothing cleared
370
+ * the record before it started, nothing registered the promise, and the sentinel was written
371
+ * AFTER the await, so a timeout skipped it entirely and the record went on naming the PRE-IMPORT
372
+ * budget while the singleton moved to an out-of-registry, un-ACL'd file. A victim session then
373
+ * passed the #390 precondition legitimately and had its reads served from the importer's file.
374
+ *
375
+ * ACCEPTED COST, recorded because nothing else states it. Registering the import means every
376
+ * subsequent `withApiLock` acquisition waits for it, bounded, and throws on timeout. An import is
377
+ * not "stuck", it is legitimately long: a large YNAB or zip import can run for minutes. So while
378
+ * one runs past ACTUAL_OP_TIMEOUT_MS, other sessions stall for that bound and then fail with
379
+ * "abandoned budget load timed out", and write drains reject their batch. That is a strictly
380
+ * better failure than the silent cross-tenant read it replaces (a victim reading the importer's
381
+ * un-ACL'd file), and it is the same trade #393 accepted for downloads. It is NOT free, and the
382
+ * operator-facing consequence is a process-wide stall for the duration of one tenant's import.
383
+ * Giving imports their own larger bound is tracked separately rather than bolted on here.
384
+ *
385
+ * No `verify` here, deliberately: unlike `download-budget`, upstream's `importBudget` already
386
+ * asserts its own post-condition. It checks `result.error` and then `!result.id`, throwing
387
+ * "no budget was loaded", so it cannot resolve without having loaded something. Verified in the
388
+ * shipped upstream source. Adding a probe would be duplicate work on the write path.
389
+ */
390
+ export async function importBudgetTracked(start, label = 'importBudget') {
391
+ // A sentinel rather than a real syncId: an imported file is outside the budget registry and
392
+ // outside the ACL, so no session can ever legitimately match it and every session's next
393
+ // operation re-selects its own budget. Configured sync ids are UUIDs, so an `imported:` prefix
394
+ // can never compare equal to one.
395
+ // #407: the import's OWN bound. See ACTUAL_IMPORT_TIMEOUT_MS in config.ts for why a long
396
+ // import must not be abandoned at the ordinary operation timeout: it is tracked, so every other
397
+ // session waits on it, and abandoning it early turns one tenant's import into a process-wide
398
+ // stall without making the import itself any faster.
399
+ return trackBudgetMutation(start, (result) => `imported:${result.id}`, label, undefined, undefined, config.ACTUAL_IMPORT_TIMEOUT_MS);
400
+ }
401
+ export async function loadBudgetTracked(syncId, encryptionPassword, label = 'downloadBudget') {
278
402
  try {
279
- await withOpTimeout(() => tracked, label);
280
- // #393 review (P3-4): NOT clearBudgetLoad(tracked). `registerBudgetLoad` registers a
281
- // derived promise, not this handle, so that delete could never match and read as a working
282
- // belt-and-braces path a later change might lean on. Self-removal on settle is what
283
- // actually deregisters a completed load, and it has already happened by the time the
284
- // awaited promise resolves here.
403
+ await trackBudgetMutation(
404
+ // #410: no redundant clear here any more. `trackBudgetMutation` owns the clear-before, and
405
+ // the guard in tests/unit/budget_selection_precondition.test.js now asserts the invariant
406
+ // that actually holds (a raw load must be inside the tracked helper) rather than looking for
407
+ // a setter in the same block, which had stopped discriminating.
408
+ () => encryptionPassword
409
+ ? api
410
+ .downloadBudget(syncId, { password: encryptionPassword })
411
+ : api.downloadBudget(syncId), () => syncId, label,
412
+ // #396: the post-condition runs INSIDE the tracked chain, before the record is written, so
413
+ // an ABANDONED load is verified when it lands rather than recording a success nobody
414
+ // checked. The network enrichment below deliberately does NOT run there.
415
+ assertBudgetOpen, () => clearCachedReason(syncId));
285
416
  }
286
417
  catch (err) {
287
- // On timeout the tracked promise is still running. Leave it REGISTERED: the next operation
288
- // waits for it inside the lock rather than racing it.
418
+ // Enrich HERE, where a caller is actually waiting. Every `withApiLock` acquisition settles
419
+ // all registrations under ONE bound and fails closed on timeout, so two network calls inside
420
+ // the tracked chain would extend the window in which unrelated sessions' lock acquisitions
421
+ // throw, to build a message an abandoned load has no caller to read.
289
422
  if (isBudgetNotOpen(err)) {
290
423
  log.error('download resolved but no budget is open', undefined, { syncId });
291
424
  throw postConditionError(syncId, await diagnose(syncId));
@@ -28,15 +28,25 @@ import config from '../config.js';
28
28
  * before `Promise.race` is built (which would orphan the armed timer and later
29
29
  * fire an unhandledRejection).
30
30
  */
31
- export function withOpTimeout(fn, label = 'operation') {
32
- const timeoutMs = config.ACTUAL_OP_TIMEOUT_MS;
31
+ export function withOpTimeout(fn, label = 'operation', overrideMs, overrideKnob = 'ACTUAL_IMPORT_TIMEOUT_MS') {
32
+ // #407: an explicit override, for an operation that is legitimately long rather than stalled.
33
+ // A budget import is the only one today. Everything else keeps the general bound, because the
34
+ // whole point of #270 is that one stalled call must not hold the process-global mutex.
35
+ const timeoutMs = overrideMs === undefined ? config.ACTUAL_OP_TIMEOUT_MS : overrideMs;
36
+ // #407 review (I4): name the knob that ACTUALLY applies. Hardcoding ACTUAL_OP_TIMEOUT_MS sent an
37
+ // operator whose import died at the import bound to raise a variable with no effect on imports.
38
+ //
39
+ // The name is a PARAMETER rather than inferred from "an override exists" (round 2): inferring it
40
+ // is correct only while exactly one call site passes an override, and the second one would
41
+ // silently report the import knob for a different bound, which is the same misdirection.
42
+ const knob = overrideMs === undefined ? 'ACTUAL_OP_TIMEOUT_MS' : overrideKnob;
33
43
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
34
44
  return fn();
35
45
  }
36
46
  let timer;
37
47
  const timeout = new Promise((_, reject) => {
38
48
  timer = setTimeout(() => {
39
- reject(new Error(`Actual API ${label} timed out after ${timeoutMs}ms (ACTUAL_OP_TIMEOUT_MS). ` +
49
+ reject(new Error(`Actual API ${label} timed out after ${timeoutMs}ms (${knob}). ` +
40
50
  'The upstream Actual server may be unreachable or stalled; the operation was ' +
41
51
  'aborted so the session can recover. Please retry the request.'));
42
52
  }, timeoutMs);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.16.5",
4
+ "version": "0.16.7",
5
5
  "engines": {
6
6
  "node": ">=22.0.0",
7
7
  "npm": ">=10.0.0"
@@ -30,7 +30,7 @@
30
30
  "verify-tools": "npm run build && node scripts/verify-tools.js",
31
31
  "check:coverage": "node scripts/list-actual-api-methods.mjs",
32
32
  "direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
33
- "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
33
+ "test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
34
34
  "test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
35
35
  "test:e2e": "npx playwright test",
36
36
  "test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",