actual-mcp-server 0.16.8 → 0.16.9

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
@@ -775,7 +775,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
775
775
 
776
776
  ---
777
777
 
778
- **Version:** 0.16.8 | **Tool Count:** 74 (verified LibreChat-compatible)
778
+ **Version:** 0.16.9 | **Tool Count:** 74 (verified LibreChat-compatible)
779
779
 
780
780
  ## Sponsor
781
781
 
@@ -548,20 +548,36 @@ class ActualConnectionPool {
548
548
  logger.info('[ConnectionPool] Force closing any stale connections from previous instance');
549
549
  // Try to shutdown the API if it was left initialized.
550
550
  //
551
- // #411: this was the LAST api.shutdown() in the codebase running without the api mutex.
552
- // #392 established that there should be none. The exposure was small (this runs at pool
553
- // construction, before anything else is live) but "small" is not "absent", and an exception
554
- // that exists only because nobody got round to it is indistinguishable from one that exists
555
- // for a reason. Swallowed like every other cleanup acquisition: a stuck load must not stop
556
- // construction.
557
- const maybeApi = api;
558
- if (typeof maybeApi.shutdown === 'function') {
559
- await withApiLock(() => maybeApi.shutdown()).catch(async (err) => {
560
- logger.debug(`[ConnectionPool] Could not take the api lock to close a stale connection: ${err}`);
561
- await maybeApi.shutdown().catch(() => { });
562
- });
563
- logger.info('[ConnectionPool] Successfully closed stale API connection');
564
- }
551
+ // #411 put this under the api mutex: it was the last api.shutdown() running without it.
552
+ // #416 then separated the two failures it was conflating. The body is a raw api.shutdown(),
553
+ // which can reject on its own, and reporting that as "could not take the api lock" sent the
554
+ // reader to the wrong subsystem while the retry repeated the call that had just failed.
555
+ //
556
+ // So: the guarded shutdown happens INSIDE the lock, matching every other shutdown path
557
+ // (which check isApiInitialized first, per #164, or a second call surfaces as
558
+ // "not initialized"). The catch below therefore genuinely means the ACQUISITION failed, and
559
+ // its fallback runs the same guarded shutdown once, unlocked and best effort.
560
+ const guardedShutdown = async () => {
561
+ if (!isApiInitialized())
562
+ return;
563
+ const maybeApi = api;
564
+ if (typeof maybeApi.shutdown !== 'function')
565
+ return;
566
+ try {
567
+ await maybeApi.shutdown();
568
+ logger.info('[ConnectionPool] Successfully closed stale API connection');
569
+ }
570
+ catch (shutdownErr) {
571
+ logger.debug(`[ConnectionPool] Stale API connection would not close (ignoring): ${shutdownErr}`);
572
+ }
573
+ finally {
574
+ setApiInitialized(false);
575
+ }
576
+ };
577
+ await withApiLock(guardedShutdown).catch(async (lockErr) => {
578
+ logger.debug(`[ConnectionPool] Could not take the api lock to close a stale connection: ${lockErr}`);
579
+ await guardedShutdown();
580
+ });
565
581
  }
566
582
  catch (err) {
567
583
  // Ignore errors - connection may not have been initialized
@@ -21,7 +21,7 @@ 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, awaitAbandonedBudgetLoad } from './apiState.js';
24
+ import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, awaitAbandonedBudgetLoad, hasPendingBudgetLoad } from './apiState.js';
25
25
  import { withApiLock } from './apiLock.js';
26
26
  import { loadBudgetTracked, importBudgetTracked } from './budgetLoader.js';
27
27
  /**
@@ -219,6 +219,25 @@ function _affinityBudget() {
219
219
  return undefined; // no registry, no hint: FIFO is always correct
220
220
  }
221
221
  }
222
+ /**
223
+ * #417: the budget every operation in a batch resolves to, or undefined when they differ.
224
+ *
225
+ * Exported so the unanimity rule can be tested without driving the lock. NOTE the side effect,
226
+ * which is bounded and idempotent but worth knowing: `getActiveBudgetConfig()` memoises a restored
227
+ * preference into `sessionBudgetState` and may read the preference file once per un-memoised
228
+ * session. The operation would do exactly that anyway a moment later, so this only moves it.
229
+ */
230
+ export function resolveBatchBudget(entries) {
231
+ const budgets = new Set(entries.map((e) => {
232
+ try {
233
+ return requestContext.run(e.requestStore ?? {}, () => getActiveBudgetConfig().syncId);
234
+ }
235
+ catch {
236
+ return undefined;
237
+ }
238
+ }));
239
+ return budgets.size === 1 ? [...budgets][0] : undefined;
240
+ }
222
241
  function _resolveSessionId() {
223
242
  return requestContext.getStore()?.sessionId;
224
243
  }
@@ -644,6 +663,26 @@ import { BANK_SYNC_SETTLE_MS, WRITE_SESSION_DELAY_MS } from './constants.js';
644
663
  // and setMaxConcurrency is re-exported.
645
664
  import { withConcurrency, setMaxConcurrency, getConcurrencySnapshot } from './actual-adapter/concurrency.js';
646
665
  export { setMaxConcurrency };
666
+ /**
667
+ * #389: how long an entry may sit DISPATCHED BUT NOT STARTED before it is rejected.
668
+ *
669
+ * Armed only when ACTUAL_OP_TIMEOUT_MS is 0, which is the one configuration where a stalled
670
+ * operation strands the rest of its batch in silence. Deliberately not derived from the op
671
+ * timeout: that is the knob the operator turned off. Fifteen minutes clears the longest
672
+ * legitimate single operation (a budget import at ACTUAL_IMPORT_TIMEOUT_MS, default ten minutes)
673
+ * with room to spare, so a healthy slow batch is never falsely rejected, while an operation that
674
+ * genuinely never runs gets an error instead of silence.
675
+ */
676
+ let STRANDED_BATCH_LIMIT_MS = 900000;
677
+ /**
678
+ * Test-only: shrink the stranded bound so the arming behaviour is testable in reasonable time.
679
+ *
680
+ * Without a seam the riskiest timer in this file could only be exercised by waiting fifteen
681
+ * minutes, which means in practice it would never be exercised at all.
682
+ */
683
+ export function _setStrandedBatchLimitForTests(ms) {
684
+ STRANDED_BATCH_LIMIT_MS = ms;
685
+ }
647
686
  let writeQueue = [];
648
687
  let isProcessingWrites = false;
649
688
  let writeSessionTimeout = null;
@@ -788,6 +827,68 @@ async function processWriteQueue() {
788
827
  entry.residencyTimer = undefined;
789
828
  }
790
829
  }
830
+ // #389: "dispatched" stopped meaning "started" when #378 made the batch SEQUENTIAL.
831
+ //
832
+ // Before that, a hanging operation left its siblings running. Now operations k+1..N never
833
+ // start, and because their residency timers were cleared just above (correctly, for the
834
+ // concurrent model) nothing rejects them: every one of those callers waits forever and NO
835
+ // ERROR IS EMITTED ANYWHERE. That is #278's signature exactly, and #278 took a "flaky" E2E
836
+ // test to surface because the only tell was the absence of an error.
837
+ //
838
+ // It needs ACTUAL_OP_TIMEOUT_MS=0 to be reachable, which is a documented escape hatch for
839
+ // debugging against a slow upstream: the one situation where a stall is most likely and an
840
+ // operator is least likely to suspect the write queue.
841
+ //
842
+ // So each entry keeps a bound on the part that is still a QUEUE WAIT. It is cleared the
843
+ // moment the operation actually starts, so it can never fire on an in-flight op, and it does
844
+ // NOT bound the operation itself, which is what the escape hatch exists to disable.
845
+ //
846
+ // The bound cannot be ACTUAL_OP_TIMEOUT_MS, because that is exactly what the operator disabled
847
+ // and what makes this reachable. It is a separate, generous constant armed ONLY in that
848
+ // configuration: setting it to 0 asks for unbounded OPERATIONS, not for unbounded QUEUEING, and
849
+ // those are different promises. It must also exceed the longest LEGITIMATE batch, or it would
850
+ // reject work that was about to run, which the ticket names as the way to get this wrong. The
851
+ // longest legitimate single operation is a budget import at ACTUAL_IMPORT_TIMEOUT_MS (default
852
+ // 600000), so 15 minutes clears it with room and is still finite.
853
+ //
854
+ // RE-ARMED on each start, not pre-armed once (review round 1). Arming all N timers at dispatch
855
+ // with the same delay makes entry k's allowance "900s minus everything before it", so a batch of
856
+ // healthy but slow operations rejects its own tail while nothing has stalled. Four budget
857
+ // re-selections at four minutes each on the slow link that made the operator disable the timeout
858
+ // in the first place is enough. Re-arming makes the bound mean what it should: no SINGLE
859
+ // predecessor has been running for 15 minutes.
860
+ const started = new Set();
861
+ const strandedLimitMs = config.ACTUAL_OP_TIMEOUT_MS === 0 ? STRANDED_BATCH_LIMIT_MS : 0;
862
+ const armStrandedBound = (entries) => {
863
+ if (strandedLimitMs <= 0)
864
+ return;
865
+ for (const entry of entries) {
866
+ if (started.has(entry) || entry.settledEarly)
867
+ continue;
868
+ if (entry.residencyTimer)
869
+ clearTimeout(entry.residencyTimer);
870
+ entry.residencyTimer = setTimeout(() => {
871
+ if (started.has(entry))
872
+ return;
873
+ logger.error(`[WRITE QUEUE] Operation never started: the operation ahead of it in this batch has not ` +
874
+ `returned within ${strandedLimitMs}ms. Rejecting rather than stranding the caller (#389).`);
875
+ entry.reject(new Error('This write never started: an earlier operation in the same batch stalled. ' +
876
+ 'The batch runs sequentially (#378), so it was queued behind that operation. Retry it.'));
877
+ entry.settledEarly = true;
878
+ }, strandedLimitMs);
879
+ if (typeof entry.residencyTimer.unref === 'function')
880
+ entry.residencyTimer.unref();
881
+ }
882
+ };
883
+ const clearStrandedBounds = () => {
884
+ for (const entry of batch) {
885
+ if (entry.residencyTimer) {
886
+ clearTimeout(entry.residencyTimer);
887
+ entry.residencyTimer = undefined;
888
+ }
889
+ }
890
+ };
891
+ armStrandedBound(batch);
791
892
  writeQueueBatchCount++;
792
893
  // Pool-cooperation decision (#158): use the first queued op's captured
793
894
  // sessionId as the batch's sessionId. In practice all ops batched together
@@ -807,6 +908,18 @@ async function processWriteQueue() {
807
908
  // assignment to clear and therefore no path, fatal or otherwise, that can leak it: when
808
909
  // the callback returns the store is gone. This replaced a module-level variable plus a
809
910
  // `finally` that nulled it, which worked but rested on that finally being reached.
911
+ // #417: hint the batch's budget, but ONLY when every operation in it resolves to the same one.
912
+ //
913
+ // #391 made an unhinted waiter a BARRIER affinity may not cross, which is what stops a drain
914
+ // being overtaken by reads queued after it. The side effect is that an unhinted drain also
915
+ // blocks affinity for everything behind it: measured at 5 re-selections for an alternating load
916
+ // with a drain in every fourth slot, against 2 with no drains.
917
+ //
918
+ // Deliberately NOT the `batch[0]` heuristic, which CLAUDE.md already names as a hazard for the
919
+ // connection choice: one entry's budget says nothing about the rest, and a wrong hint would
920
+ // reorder a mixed batch. Unanimity or nothing. The hint is only ever an ordering preference;
921
+ // `ensureLoadedBudgetMatchesSession` inside the lock still decides what is safe.
922
+ const batchBudget = resolveBatchBudget(batch);
810
923
  await drainListingStore.run(new Map(), async () => {
811
924
  await withApiLock(async () => {
812
925
  try {
@@ -851,7 +964,43 @@ async function processWriteQueue() {
851
964
  //
852
965
  // Each op keeps its own withOpTimeout (#270) and still settles individually, so one
853
966
  // stalled or rejected operation cannot hold the api mutex or fail its siblings.
854
- for (const { operation, resolve, reject, preservesListings, requestStore, timeoutMs } of batch) {
967
+ let stuckLoadError = null;
968
+ // Counted so the event is logged ONCE with a total. Without this the fail-fast path sits
969
+ // outside the per-operation try, so it never reaches the '[WRITE QUEUE] Operation failed'
970
+ // line and N operations fail with no log entry anywhere. Given this whole ticket family
971
+ // exists because absence-of-error is the failure signature, that would be the wrong silence
972
+ // to introduce while removing another.
973
+ let failedFast = 0;
974
+ for (const entry of batch) {
975
+ const { operation, resolve, reject, preservesListings, requestStore, timeoutMs } = entry;
976
+ // #389: this operation is STARTING, so its queue-wait bound no longer applies. Cleared
977
+ // before any await, so the timer can never fire on an in-flight op.
978
+ started.add(entry);
979
+ if (entry.residencyTimer) {
980
+ clearTimeout(entry.residencyTimer);
981
+ entry.residencyTimer = undefined;
982
+ }
983
+ // Restart the bound for everything still waiting, so it measures THIS operation's
984
+ // runtime rather than the batch's cumulative one.
985
+ armStrandedBound(batch);
986
+ if (entry.settledEarly)
987
+ continue; // its residency bound already rejected it
988
+ // #414: fail FAST while a budget load is genuinely stuck, rather than paying the
989
+ // abandoned-load bound once per remaining operation. A stuck load makes every one of
990
+ // them fail identically, so N operations would hold the process-global mutex for N
991
+ // times ACTUAL_OP_TIMEOUT_MS. The condition is RE-CHECKED rather than latched, because
992
+ // a load can land just after a bound expires and the whole point of keeping the
993
+ // registration is that a late landing is still waited for.
994
+ if (stuckLoadError !== null && hasPendingBudgetLoad()) {
995
+ // A DISTINCT error, not the one the earlier operation's wait produced (review round 1).
996
+ // Handing this operation that error claims it waited and timed out, which it never did,
997
+ // and its message matches TRANSIENT_ERROR_PATTERNS while describing something that is
998
+ // not this operation's failure. `cause` keeps the real origin attached.
999
+ failedFast++;
1000
+ reject(new Error('This write was queued behind an operation whose budget load is still outstanding, ' +
1001
+ 'so it was failed immediately rather than waiting for the same bound. Retry it.', { cause: stuckLoadError }));
1002
+ continue;
1003
+ }
855
1004
  try {
856
1005
  // #390 round 2: run each op in the context it was ENQUEUED in.
857
1006
  //
@@ -887,7 +1036,15 @@ async function processWriteQueue() {
887
1036
  // A rejection here is caught by this loop's own per-op catch, so a stuck load fails
888
1037
  // ONE operation rather than the whole batch, which is the behaviour #393 round 4
889
1038
  // established for the drain.
890
- await awaitAbandonedBudgetLoad(withOpTimeout);
1039
+ try {
1040
+ await awaitAbandonedBudgetLoad(withOpTimeout);
1041
+ }
1042
+ catch (waitErr) {
1043
+ // #414: remember it, so the rest of the batch fails fast instead of each paying
1044
+ // the same bound. Still rethrown, so THIS operation fails exactly as before.
1045
+ stuckLoadError = waitErr instanceof Error ? waitErr : new Error(String(waitErr));
1046
+ throw waitErr;
1047
+ }
891
1048
  await ensureLoadedBudgetMatchesSession();
892
1049
  // #407: an operation may carry its OWN bound. Only a budget import does today, and
893
1050
  // it needs one: the import is legitimately long, and bounding it at the general
@@ -952,6 +1109,10 @@ async function processWriteQueue() {
952
1109
  connectionPool.touch(sid);
953
1110
  }
954
1111
  }
1112
+ if (failedFast > 0) {
1113
+ logger.error(`[WRITE QUEUE] ${failedFast} operation(s) failed fast because a budget load is still ` +
1114
+ `outstanding. They were never attempted; the stalled load is the cause (#414).`);
1115
+ }
955
1116
  // Explicitly sync changes to server before shutdown (legacy) or just
956
1117
  // before returning (pool). Persistence guarantee in both branches.
957
1118
  logger.debug(`[WRITE QUEUE] Syncing ${batch.length} operations to server`);
@@ -1015,10 +1176,19 @@ async function processWriteQueue() {
1015
1176
  await shutdownActualApi();
1016
1177
  }
1017
1178
  }
1018
- });
1179
+ }, { budget: batchBudget });
1019
1180
  });
1181
+ clearStrandedBounds();
1020
1182
  }
1021
1183
  catch (lockError) {
1184
+ // #389 review (I2): clear the stranded bounds here too. When the ACQUISITION rejects (which it
1185
+ // can, since #393, on an abandoned-load timeout) the loop is never entered, so without this all
1186
+ // N timers stay armed and each one logs "the operation ahead of it in this batch has not
1187
+ // returned" fifteen minutes later, for a batch that no longer exists and whose failure was a
1188
+ // lock acquisition rather than a stalled sibling. The rejects are harmless no-ops on settled
1189
+ // promises; the false signal is the problem, and it is the same wrong-subsystem misdirection
1190
+ // #416 exists to remove.
1191
+ clearStrandedBounds();
1022
1192
  // #393 review: THE LOCK ITSELF CAN NOW REJECT, before `fn()` ever runs.
1023
1193
  //
1024
1194
  // The batch-rejection handler above lives INSIDE the lock callback, which was sound while
@@ -149,8 +149,10 @@ function grantNext() {
149
149
  //
150
150
  // Review caught this, and it was the worst thing in the change. An unhinted waiter can never
151
151
  // equal `loaded`, so without this it was freely skipped, and the three unhinted acquisitions
152
- // are the ones least able to afford it: the write drain, the pool's session open, and
153
- // `shutdownAll`. Reproduced on a SINGLE-budget setup, where affinity can never save a
152
+ // are the ones least able to afford it: the pool's session open, `shutdownAll`, and a write
153
+ // drain whose batch spans budgets. (#417 later let a UNANIMOUS drain hint its own budget, so
154
+ // that case is no longer a barrier. A mixed batch still is, and must stay so: one entry's
155
+ // budget says nothing about the rest.) Reproduced on a SINGLE-budget setup, where affinity can never save a
154
156
  // re-selection because only one budget is ever loaded: a drain enqueued first ran ninth,
155
157
  // behind eight reads. So the majority deployment got the reordering with none of the benefit,
156
158
  // a write could be overtaken by reads issued after it (impossible under the old FIFO, and
@@ -127,6 +127,20 @@ export async function awaitAbandonedBudgetLoad(bound) {
127
127
  await bound(() => outstanding, 'abandoned budget load');
128
128
  return true;
129
129
  }
130
+ /**
131
+ * Is a budget load currently outstanding?
132
+ *
133
+ * Production predicate (#414). The write drain uses it to FAIL FAST rather than pay the abandoned
134
+ * load bound once per remaining operation: a stuck load makes every one of them fail identically,
135
+ * so N operations cost N times ACTUAL_OP_TIMEOUT_MS holding the process-global mutex.
136
+ *
137
+ * Deliberately re-checked per operation rather than latched: a load can land just after a bound
138
+ * expires, and the whole point of keeping the registration is that a late landing is still waited
139
+ * for. Fail fast only while the condition still holds.
140
+ */
141
+ export function hasPendingBudgetLoad() {
142
+ return _pendingBudgetLoads.size > 0;
143
+ }
130
144
  /** Test hook: is a load currently outstanding? */
131
145
  export function _hasPendingBudgetLoadForTests() {
132
146
  return _pendingBudgetLoads.size > 0;
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.8",
4
+ "version": "0.16.9",
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_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/api_lock_affinity.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/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.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",