actual-mcp-server 0.16.7 → 0.16.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/src/lib/ActualConnectionPool.js +77 -16
- package/dist/src/lib/actual-adapter.js +26 -5
- package/dist/src/lib/apiLock.js +196 -36
- package/dist/src/lib/budgetLoader.js +51 -3
- package/package.json +2 -2
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.
|
|
778
|
+
**Version:** 0.16.8 | **Tool Count:** 74 (verified LibreChat-compatible)
|
|
779
779
|
|
|
780
780
|
## Sponsor
|
|
781
781
|
|
|
@@ -17,7 +17,7 @@ import path from 'path';
|
|
|
17
17
|
import os from 'os';
|
|
18
18
|
import fs from 'fs';
|
|
19
19
|
import { isApiInitialized, setApiInitialized } from './apiState.js';
|
|
20
|
-
import { withApiLock } from './apiLock.js';
|
|
20
|
+
import { withApiLock, isApiLockHeld } from './apiLock.js';
|
|
21
21
|
import { loadBudgetTracked } from './budgetLoader.js';
|
|
22
22
|
import { withOpTimeout } from './opTimeout.js';
|
|
23
23
|
const DEFAULT_DATA_DIR = path.resolve(os.homedir() || '.', '.actual');
|
|
@@ -417,7 +417,7 @@ class ActualConnectionPool {
|
|
|
417
417
|
await withApiLock(() => this.shutdownConnectionLocked(sessionId, opts)).catch(async (err) => {
|
|
418
418
|
logger.error(`[ConnectionPool] Could not acquire the api lock to shut down ${sessionId}; ` +
|
|
419
419
|
`tearing the entry down without it: ${err instanceof Error ? err.message : String(err)}`);
|
|
420
|
-
await this.shutdownConnectionLocked(sessionId, opts).catch(() => { });
|
|
420
|
+
await this.shutdownConnectionLocked(sessionId, { ...opts, expectUnlocked: true }).catch(() => { });
|
|
421
421
|
});
|
|
422
422
|
}
|
|
423
423
|
/**
|
|
@@ -434,9 +434,24 @@ class ActualConnectionPool {
|
|
|
434
434
|
* - actual-adapter switchBudget slow path (its own withApiLock block has closed by then)
|
|
435
435
|
* - actualConnection.shutdownActualForSession (session close)
|
|
436
436
|
* - this.cleanupIdleConnections (the idle sweep)
|
|
437
|
-
*
|
|
437
|
+
*
|
|
438
|
+
* Since #412, `shutdownAll` HOLDS the lock (one acquisition for the whole teardown instead of
|
|
439
|
+
* one per session) and therefore calls this variant directly. Its fallback path runs unlocked
|
|
440
|
+
* on purpose and passes `expectUnlocked` to say so.
|
|
438
441
|
*/
|
|
439
442
|
async shutdownConnectionLocked(sessionId, opts = {}) {
|
|
443
|
+
// #411: assert the precondition rather than documenting it. The audit above says which callers
|
|
444
|
+
// hold the lock; this is what keeps that audit true as call sites are added.
|
|
445
|
+
//
|
|
446
|
+
// A WARNING, not a throw, and deliberately so: this is reached from cleanup paths, and a false
|
|
447
|
+
// positive that threw would turn a tidy-up into an outage, which is a worse trade than the
|
|
448
|
+
// hazard it guards. The nearest legitimate exception is the fallback in shutdownConnection
|
|
449
|
+
// itself, which runs unlocked ON PURPOSE when the acquisition failed, so it passes
|
|
450
|
+
// `expectUnlocked` to say so.
|
|
451
|
+
if (!opts.expectUnlocked && !isApiLockHeld()) {
|
|
452
|
+
logger.warn(`[ConnectionPool] shutdownConnectionLocked called for ${sessionId} without the api mutex. ` +
|
|
453
|
+
'Callers that do not hold it must use shutdownConnection, which acquires it.');
|
|
454
|
+
}
|
|
440
455
|
const conn = this.connections.get(sessionId);
|
|
441
456
|
// #392 review: the idle sweep decides who is expired BEFORE waiting for the mutex, and that
|
|
442
457
|
// wait is no longer negligible. Re-checking here means a session that was touched while we
|
|
@@ -479,11 +494,16 @@ class ActualConnectionPool {
|
|
|
479
494
|
}
|
|
480
495
|
}
|
|
481
496
|
/**
|
|
482
|
-
*
|
|
497
|
+
* Acquire the mutex and tear down the shared connection.
|
|
498
|
+
*
|
|
499
|
+
* #412 left this with NO caller in `src/`: `shutdownAll` now holds the lock itself and calls the
|
|
500
|
+
* Locked variant. It is kept, rather than deleted, because `USE_CONNECTION_POOL=false` uses the
|
|
501
|
+
* shared connection and a future teardown path for it must not hand-roll the acquisition. That
|
|
502
|
+
* is a deliberate decision rather than an oversight: knip does not analyse class members, so
|
|
503
|
+
* nothing else would have flagged it, and CLAUDE.md's own lesson from `adapter.deleteRule` is
|
|
504
|
+
* that a callerless wrapper left reachable is how the next caller finds the unguarded path.
|
|
483
505
|
*/
|
|
484
506
|
async shutdownSharedConnection() {
|
|
485
|
-
// #392: same reasoning as shutdownConnection. Its only caller is shutdownAll, which does not
|
|
486
|
-
// hold the mutex.
|
|
487
507
|
await withApiLock(() => this.shutdownSharedConnectionLocked()).catch(async (err) => {
|
|
488
508
|
logger.error(`[ConnectionPool] Could not acquire the api lock to shut down the shared connection; ` +
|
|
489
509
|
`tearing it down without it: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -491,7 +511,14 @@ class ActualConnectionPool {
|
|
|
491
511
|
});
|
|
492
512
|
}
|
|
493
513
|
/** The body of shutdownSharedConnection, for callers that already hold the api mutex. */
|
|
494
|
-
async shutdownSharedConnectionLocked() {
|
|
514
|
+
async shutdownSharedConnectionLocked(opts = {}) {
|
|
515
|
+
// #411: same precondition check as shutdownConnectionLocked. Round 2 pointed out that keeping
|
|
516
|
+
// the callerless wrapper (M3) is only defensible if the guard that would catch a future
|
|
517
|
+
// hand-rolled caller exists on BOTH variants.
|
|
518
|
+
if (!opts.expectUnlocked && !isApiLockHeld()) {
|
|
519
|
+
logger.warn('[ConnectionPool] shutdownSharedConnectionLocked called without the api mutex. ' +
|
|
520
|
+
'Callers that do not hold it must use shutdownSharedConnection, which acquires it.');
|
|
521
|
+
}
|
|
495
522
|
if (!this.sharedConnection?.initialized) {
|
|
496
523
|
return;
|
|
497
524
|
}
|
|
@@ -519,10 +546,20 @@ class ActualConnectionPool {
|
|
|
519
546
|
async forceCloseStaleConnections() {
|
|
520
547
|
try {
|
|
521
548
|
logger.info('[ConnectionPool] Force closing any stale connections from previous instance');
|
|
522
|
-
// Try to shutdown the API if it was left initialized
|
|
549
|
+
// Try to shutdown the API if it was left initialized.
|
|
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.
|
|
523
557
|
const maybeApi = api;
|
|
524
558
|
if (typeof maybeApi.shutdown === 'function') {
|
|
525
|
-
await maybeApi.shutdown()
|
|
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
|
+
});
|
|
526
563
|
logger.info('[ConnectionPool] Successfully closed stale API connection');
|
|
527
564
|
}
|
|
528
565
|
}
|
|
@@ -596,13 +633,37 @@ class ActualConnectionPool {
|
|
|
596
633
|
// singleton ("not initialized" during graceful shutdown). Sessions are few
|
|
597
634
|
// (15 max) and shutdown is rare, so sequential is fine. The first call does
|
|
598
635
|
// the real shutdown; the isApiInitialized() guard makes the rest no-ops.
|
|
599
|
-
//
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
636
|
+
// #412: ONE acquisition for the whole teardown, not one per session.
|
|
637
|
+
//
|
|
638
|
+
// Since #392 each shutdownConnection takes the api mutex, and since #393 acquiring it settles
|
|
639
|
+
// any abandoned budget load, bounded, and throws on timeout while KEEPING the registration. So
|
|
640
|
+
// with a stuck load, N sessions cost N times ACTUAL_OP_TIMEOUT_MS: 15 sessions at the 30s
|
|
641
|
+
// default is 450 seconds, and Docker sends SIGKILL after a 10 second grace period. The
|
|
642
|
+
// container died with a page of "could not acquire" lines and no clean api.shutdown().
|
|
643
|
+
//
|
|
644
|
+
// Acquiring once also makes the teardown ONE critical section rather than 16, which is more
|
|
645
|
+
// correct: nothing can interleave between the sessions being torn down.
|
|
646
|
+
//
|
|
647
|
+
// Swallowed for the same reason the per-session wrapper swallows: this is a cleanup path, and
|
|
648
|
+
// a stuck load must not stop the entries being removed. The fallback repeats the loop with the
|
|
649
|
+
// Locked variants, which is safe because nothing can be holding the lock while a registration
|
|
650
|
+
// is stuck (every acquisition throws in that state).
|
|
651
|
+
const teardown = async (expectUnlocked = false) => {
|
|
652
|
+
// Snapshot the keys: shutdownConnectionLocked deletes from `connections`.
|
|
653
|
+
for (const sessionId of [...this.connections.keys()]) {
|
|
654
|
+
await this.shutdownConnectionLocked(sessionId, { expectUnlocked });
|
|
655
|
+
}
|
|
656
|
+
if (this.sharedConnection?.initialized) {
|
|
657
|
+
await this.shutdownSharedConnectionLocked({ expectUnlocked });
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
await withApiLock(() => teardown()).catch(async (err) => {
|
|
661
|
+
logger.error(`[ConnectionPool] Could not acquire the api lock for shutdownAll; tearing down without it: ` +
|
|
662
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
663
|
+
// `expectUnlocked`: this path runs without the mutex ON PURPOSE, so #411's warning would
|
|
664
|
+
// otherwise fire once per session at the exact moment the code chose not to take it.
|
|
665
|
+
await teardown(true).catch(() => { });
|
|
666
|
+
});
|
|
606
667
|
logger.info('[ConnectionPool] All connections shut down');
|
|
607
668
|
}
|
|
608
669
|
/**
|
|
@@ -200,6 +200,25 @@ let connectionReuseCount = 0;
|
|
|
200
200
|
// update it without a circular import. The pool's hasConnection() returns
|
|
201
201
|
// true based on its own per-session record; this flag is the second guard
|
|
202
202
|
// — the singleton's actual state. Both must agree before reuse is safe.
|
|
203
|
+
/**
|
|
204
|
+
* #391: the budget this acquisition will operate on, as an AFFINITY HINT for the api lock.
|
|
205
|
+
*
|
|
206
|
+
* Never an assertion, and never used for a correctness decision: the loaded-budget PRECONDITION
|
|
207
|
+
* (`ensureLoadedBudgetMatchesSession`) is what makes an operation safe, and it runs inside the
|
|
208
|
+
* lock regardless of how the lock chose to order us. This only lets the lock group a run of
|
|
209
|
+
* same-budget work so the process pays one re-selection instead of one per call.
|
|
210
|
+
*
|
|
211
|
+
* Resolved from the same source the precondition uses, so a hint can never name a budget the
|
|
212
|
+
* operation would not have selected anyway.
|
|
213
|
+
*/
|
|
214
|
+
function _affinityBudget() {
|
|
215
|
+
try {
|
|
216
|
+
return getActiveBudgetConfig().syncId;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return undefined; // no registry, no hint: FIFO is always correct
|
|
220
|
+
}
|
|
221
|
+
}
|
|
203
222
|
function _resolveSessionId() {
|
|
204
223
|
return requestContext.getStore()?.sessionId;
|
|
205
224
|
}
|
|
@@ -331,6 +350,8 @@ export async function withActualApi(rawOperation) {
|
|
|
331
350
|
const sessionId = _resolveSessionId();
|
|
332
351
|
if (_hasPooledConnection(sessionId)) {
|
|
333
352
|
// Pooled mode: skip init+shutdown.
|
|
353
|
+
// #391: name the budget this operation will run against, so the lock can group a run of
|
|
354
|
+
// same-budget work and pay ONE re-selection instead of one per call.
|
|
334
355
|
return withApiLock(async () => {
|
|
335
356
|
try {
|
|
336
357
|
connectionReuseCount++;
|
|
@@ -355,7 +376,7 @@ export async function withActualApi(rawOperation) {
|
|
|
355
376
|
}
|
|
356
377
|
throw err;
|
|
357
378
|
}
|
|
358
|
-
});
|
|
379
|
+
}, { budget: _affinityBudget() });
|
|
359
380
|
}
|
|
360
381
|
if (sessionId) {
|
|
361
382
|
logger.warn(`[ADAPTER] Pool miss for session ${sessionId}; falling back to per-op init`);
|
|
@@ -369,7 +390,7 @@ export async function withActualApi(rawOperation) {
|
|
|
369
390
|
finally {
|
|
370
391
|
await shutdownActualApi();
|
|
371
392
|
}
|
|
372
|
-
});
|
|
393
|
+
}, { budget: _affinityBudget() });
|
|
373
394
|
}
|
|
374
395
|
/**
|
|
375
396
|
* Variant of `withActualApi` for write operations. Identical to `withActualApi`
|
|
@@ -431,7 +452,7 @@ export async function withActualApiWrite(operation) {
|
|
|
431
452
|
}
|
|
432
453
|
throw err;
|
|
433
454
|
}
|
|
434
|
-
});
|
|
455
|
+
}, { budget: _affinityBudget() });
|
|
435
456
|
}
|
|
436
457
|
if (sessionId) {
|
|
437
458
|
logger.warn(`[ADAPTER] Pool miss for session ${sessionId}; falling back to per-op init (write)`);
|
|
@@ -444,7 +465,7 @@ export async function withActualApiWrite(operation) {
|
|
|
444
465
|
finally {
|
|
445
466
|
await shutdownActualApi();
|
|
446
467
|
}
|
|
447
|
-
});
|
|
468
|
+
}, { budget: _affinityBudget() });
|
|
448
469
|
}
|
|
449
470
|
/**
|
|
450
471
|
* Test-only: reset the connection-reuse counter. NOT exported via the package
|
|
@@ -1398,7 +1419,7 @@ export async function setBudgetAmount(month, categoryId, amount) {
|
|
|
1398
1419
|
export async function transferBudgetAmount(month, fromCategoryId, toCategoryId, amount) {
|
|
1399
1420
|
observability.incrementToolCall('actual.budgets.transfer').catch(() => { });
|
|
1400
1421
|
return queueWriteOperation(async () => {
|
|
1401
|
-
// Inside processWriteQueue we already hold
|
|
1422
|
+
// Inside processWriteQueue we already hold the api mutex in apiLock.ts and the api is
|
|
1402
1423
|
// initialised. Call raw functions only: adapter wrappers would re-enter
|
|
1403
1424
|
// queueWriteOperation / withActualApi and defeat the single-cycle goal.
|
|
1404
1425
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
package/dist/src/lib/apiLock.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { awaitAbandonedBudgetLoad } from './apiState.js';
|
|
1
|
+
import { awaitAbandonedBudgetLoad, getLoadedBudgetSyncId } from './apiState.js';
|
|
2
2
|
import { withOpTimeout } from './opTimeout.js';
|
|
3
3
|
import { createModuleLogger } from './loggerFactory.js';
|
|
4
4
|
const logger = createModuleLogger('API LOCK');
|
|
@@ -17,44 +17,204 @@ const logger = createModuleLogger('API LOCK');
|
|
|
17
17
|
* this first: reads via `withActualApi`, writes via `processWriteQueue`, and since #390 the
|
|
18
18
|
* pool's session-open init and budget download.
|
|
19
19
|
*
|
|
20
|
-
* IT IS NOT REENTRANT, and that is load bearing.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
20
|
+
* IT IS NOT REENTRANT, and that is load bearing. An inner acquisition queues behind the outer
|
|
21
|
+
* call's own release, which cannot happen until the outer callback resolves. The observable
|
|
22
|
+
* symptom is a stall ended by #270's operation timeout, NOT a hang, and it should be read as a
|
|
23
|
+
* probable nesting bug rather than a slow upstream. Before adding an acquisition, check that no
|
|
24
|
+
* caller above you already holds it.
|
|
25
25
|
*/
|
|
26
|
-
let _apiSessionLock = Promise.resolve();
|
|
27
26
|
/**
|
|
28
|
-
* #
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
27
|
+
* #391: BUDGET AFFINITY, replacing strict FIFO.
|
|
28
|
+
*
|
|
29
|
+
* The problem this solves, measured rather than asserted: with two sessions on different
|
|
30
|
+
* budgets, 20 alternating tool calls produced 19 full budget downloads, 19 syncs of the outgoing
|
|
31
|
+
* budget and 19 post-condition probes, all serialised through this mutex. That is #390's
|
|
32
|
+
* isolation working correctly and costing a re-selection per operation. On a large budget the
|
|
33
|
+
* download can exceed ACTUAL_OP_TIMEOUT_MS, at which point neither user completes anything while
|
|
34
|
+
* the other is active, and any two authenticated accounts on different budgets can hold the
|
|
35
|
+
* process there deliberately.
|
|
36
|
+
*
|
|
37
|
+
* The fix is to serialise by BUDGET rather than by operation: when the lock is released, prefer a
|
|
38
|
+
* waiter whose budget is already loaded. A run of same-budget operations then pays ONE
|
|
39
|
+
* re-selection instead of one each, so the cost tracks ALTERNATIONS rather than call count.
|
|
40
|
+
*
|
|
41
|
+
* THE MUTEX SHAPE CHANGED to make this possible. It was a chained-promise FIFO, which has no
|
|
42
|
+
* waiter list to reorder. It is now an explicit queue with a held flag. Three properties are
|
|
43
|
+
* load bearing, and each has a mutation-proven test in tests/unit/api_lock_affinity.test.js:
|
|
44
|
+
*
|
|
45
|
+
* 1. BOUNDED STARVATION. Preferring the loaded budget indefinitely starves the other one, which
|
|
46
|
+
* is a failure mode strict FIFO did not have. At most MAX_AFFINITY_SKIPS consecutive grants
|
|
47
|
+
* may skip the head of the queue; the next grant then goes to the head regardless of budget.
|
|
48
|
+
* 2. FIFO WITHIN A BUDGET. Among waiters on the same budget the oldest is always chosen.
|
|
49
|
+
* Several correctness arguments elsewhere rest on same-session ordering.
|
|
50
|
+
* 3. THE ABANDONED-LOAD WAIT STILL HAPPENS on every acquisition (#393), and per operation inside
|
|
51
|
+
* a drain (#406). Reordering acquisitions must not let one skip it.
|
|
52
|
+
*/
|
|
53
|
+
/**
|
|
54
|
+
* How many consecutive grants may skip the head of the queue.
|
|
55
|
+
*
|
|
56
|
+
* The trade is thrash against latency, and neither end is free. Zero is strict FIFO, which is
|
|
57
|
+
* what #391 exists to stop. Unbounded starves the other budget entirely.
|
|
58
|
+
*
|
|
59
|
+
* Eight, because a switch costs three upstream operations (sync the outgoing budget, download the
|
|
60
|
+
* incoming one, probe it), so amortising one switch over eight operations puts the per-operation
|
|
61
|
+
* cost well below the un-batched case, while the waiter at the head of the queue can be delayed by
|
|
62
|
+
* at most eight operations rather than indefinitely. It is deliberately a small number: the goal
|
|
63
|
+
* is to remove the per-CALL switch, not to let one tenant monopolise the process.
|
|
64
|
+
*
|
|
65
|
+
* The bound is in OPERATIONS, not wall clock, and the difference matters in the scenario this
|
|
66
|
+
* ticket cites. Eight operations that each run to ACTUAL_OP_TIMEOUT_MS is 240 seconds at the
|
|
67
|
+
* default, which is past most clients' own request timeouts. That is the accepted trade: bounding
|
|
68
|
+
* by time instead would need the lock to know how long an operation has left, which it cannot.
|
|
45
69
|
*/
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
70
|
+
const MAX_AFFINITY_SKIPS = 8;
|
|
71
|
+
let held = false;
|
|
72
|
+
const waiters = [];
|
|
73
|
+
let consecutiveSkips = 0;
|
|
74
|
+
/**
|
|
75
|
+
* The release path's diagnostic, behind a seam.
|
|
76
|
+
*
|
|
77
|
+
* Not indirection for its own sake: this exists so a test can install a THROWING hook and prove
|
|
78
|
+
* the lock is still released. Round 2 caught the previous test asserting source-text ordering
|
|
79
|
+
* instead, which stayed green when the exact regression was reintroduced a few lines away.
|
|
80
|
+
*/
|
|
81
|
+
let onSkipGrant = (info) => {
|
|
82
|
+
logger.debug('granting out of order to keep the loaded budget', info);
|
|
83
|
+
};
|
|
84
|
+
export function _setApiLockLogHookForTests(hook) {
|
|
85
|
+
onSkipGrant = hook ?? ((info) => {
|
|
86
|
+
logger.debug('granting out of order to keep the loaded budget', info);
|
|
51
87
|
});
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
88
|
+
}
|
|
89
|
+
/** Test hook: the queue is module state, so a suite must be able to observe and reset it. */
|
|
90
|
+
export function _getApiLockStateForTests() {
|
|
91
|
+
return { held, waiting: waiters.length, consecutiveSkips };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* #411: is the api mutex currently held?
|
|
95
|
+
*
|
|
96
|
+
* Exists so the `...Locked` variants can ASSERT their precondition instead of documenting it. The
|
|
97
|
+
* audit of which callers hold the lock lives in a doc comment today, and this repo has paid four
|
|
98
|
+
* times (#371, #376, #390, #393) for the difference between a convention and a check.
|
|
99
|
+
*
|
|
100
|
+
* Deliberately coarse: it reports that SOMEONE holds the lock, not that YOU do, because a
|
|
101
|
+
* chained-promise mutex has no owner identity. That is still enough to catch the mistake that
|
|
102
|
+
* matters, which is calling a Locked variant from a path that holds nothing at all.
|
|
103
|
+
*/
|
|
104
|
+
export function isApiLockHeld() {
|
|
105
|
+
return held;
|
|
106
|
+
}
|
|
107
|
+
export function _resetApiLockForTests() {
|
|
108
|
+
// REJECT queued waiters rather than granting or dropping them.
|
|
109
|
+
//
|
|
110
|
+
// Dropping leaves promises that never settle (the hazard `_clearPendingBudgetLoadsForTests`
|
|
111
|
+
// documents in apiState.ts). Granting is worse and was the first attempt: a granted waiter RUNS
|
|
112
|
+
// ITS BODY and then calls grantNext(), releasing a lock it was never exclusively handed. Probed
|
|
113
|
+
// at four concurrent holders. Rejecting settles the promise without running anything and without
|
|
114
|
+
// a spurious release, which is what "reset" should mean.
|
|
115
|
+
const pending = waiters.splice(0, waiters.length);
|
|
116
|
+
held = false;
|
|
117
|
+
consecutiveSkips = 0;
|
|
118
|
+
for (const w of pending)
|
|
119
|
+
w.abort(new Error('api lock reset by a test hook'));
|
|
120
|
+
}
|
|
121
|
+
function acquire(budget) {
|
|
122
|
+
if (!held) {
|
|
123
|
+
held = true;
|
|
124
|
+
return Promise.resolve();
|
|
125
|
+
}
|
|
126
|
+
return new Promise((grant, abort) => {
|
|
127
|
+
waiters.push({ budget, grant, abort });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Hand the lock to exactly one waiter, or release it if none are queued.
|
|
132
|
+
*
|
|
133
|
+
* Ownership TRANSFERS: `held` stays true when a waiter is granted, so no third party can slip in
|
|
134
|
+
* between the release and the grant. That is what makes this a mutex rather than a suggestion.
|
|
135
|
+
*/
|
|
136
|
+
function grantNext() {
|
|
137
|
+
if (waiters.length === 0) {
|
|
138
|
+
held = false;
|
|
139
|
+
consecutiveSkips = 0;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
let index = 0; // strict FIFO by default, which is also the anti-starvation fallback
|
|
143
|
+
if (consecutiveSkips < MAX_AFFINITY_SKIPS) {
|
|
144
|
+
const loaded = getLoadedBudgetSyncId();
|
|
145
|
+
if (loaded !== null) {
|
|
146
|
+
// The OLDEST waiter on the loaded budget, so ordering within a budget is preserved.
|
|
147
|
+
const match = waiters.findIndex((w) => w.budget === loaded);
|
|
148
|
+
// An UNHINTED waiter is a BARRIER that affinity may not cross.
|
|
149
|
+
//
|
|
150
|
+
// Review caught this, and it was the worst thing in the change. An unhinted waiter can never
|
|
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
|
|
154
|
+
// re-selection because only one budget is ever loaded: a drain enqueued first ran ninth,
|
|
155
|
+
// behind eight reads. So the majority deployment got the reordering with none of the benefit,
|
|
156
|
+
// a write could be overtaken by reads issued after it (impossible under the old FIFO, and
|
|
157
|
+
// visible to a client issuing parallel tool calls), and #412's teardown could be delayed by
|
|
158
|
+
// eight operations at exactly the moment it is racing SIGTERM.
|
|
159
|
+
//
|
|
160
|
+
// Skipping a same-budget waiter that is merely LATER in the queue is safe; skipping one that
|
|
161
|
+
// cannot express a preference is not, because "no hint" does not mean "no ordering
|
|
162
|
+
// requirement".
|
|
163
|
+
const barrier = waiters.findIndex((w) => w.budget === undefined);
|
|
164
|
+
if (match > 0 && (barrier === -1 || match < barrier))
|
|
165
|
+
index = match;
|
|
56
166
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
167
|
+
}
|
|
168
|
+
if (index > 0) {
|
|
169
|
+
consecutiveSkips++;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
// The head ran, so nobody is being starved right now.
|
|
173
|
+
consecutiveSkips = 0;
|
|
174
|
+
}
|
|
175
|
+
// GRANT FIRST, log after, and never let the log break the release.
|
|
176
|
+
//
|
|
177
|
+
// This runs in a `finally`, so anything that throws here leaves `held` true with a queue nobody
|
|
178
|
+
// will ever be handed. No timeout can rescue that: the waiters never enter the lock body, so
|
|
179
|
+
// `withOpTimeout` never applies to them. It is #278's signature, and CLAUDE.md's own line is
|
|
180
|
+
// that a hang has no timeout large enough. The previous chained-promise mutex released with a
|
|
181
|
+
// bare `resolve()`, which could not throw; this queue can, so the ordering is deliberate.
|
|
182
|
+
const skipped = index;
|
|
183
|
+
const [chosen] = waiters.splice(index, 1);
|
|
184
|
+
chosen.grant();
|
|
185
|
+
if (skipped > 0) {
|
|
186
|
+
try {
|
|
187
|
+
onSkipGrant({ skipped, consecutiveSkips, waiting: waiters.length });
|
|
188
|
+
}
|
|
189
|
+
catch { /* the release path must never throw */ }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Run `fn` holding the api mutex.
|
|
194
|
+
*
|
|
195
|
+
* `budget` is an optional AFFINITY HINT, not an assertion: pass the syncId this acquisition will
|
|
196
|
+
* operate on when the caller can name one unambiguously. Callers that cannot (a write drain whose
|
|
197
|
+
* batch spans budgets) pass nothing and are treated as ordinary FIFO waiters, which is always
|
|
198
|
+
* correct and merely forgoes the optimisation.
|
|
199
|
+
*/
|
|
200
|
+
export function withApiLock(fn, opts) {
|
|
201
|
+
return acquire(opts?.budget).then(async () => {
|
|
202
|
+
try {
|
|
203
|
+
// #393: settling an abandoned budget load is part of ACQUIRING the lock, not something call
|
|
204
|
+
// sites remember to do. Every previous round of #390 guarded per call site and every round
|
|
205
|
+
// missed one. Putting it here makes the set of call sites stop mattering: nothing reaches
|
|
206
|
+
// @actual-app/api without this lock, so nothing reaches it without the wait.
|
|
207
|
+
//
|
|
208
|
+
// It is bounded and fails closed (see `awaitAbandonedBudgetLoad`): an unbounded wait here
|
|
209
|
+
// would wedge every session on one stuck download, which is what made #390's round-2
|
|
210
|
+
// version worse than the bug it fixed.
|
|
211
|
+
if (await awaitAbandonedBudgetLoad(withOpTimeout)) {
|
|
212
|
+
logger.warn('waited for an abandoned budget load before running this operation');
|
|
213
|
+
}
|
|
214
|
+
return await fn();
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
grantNext();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
60
220
|
}
|
|
@@ -328,10 +328,49 @@ async function trackBudgetMutation(start, describeLoaded, label, verify, onRecor
|
|
|
328
328
|
// "first" untrue.
|
|
329
329
|
setLoadedBudgetSyncId(null);
|
|
330
330
|
const raw = start();
|
|
331
|
+
// #403: was this load ABANDONED by the caller's bound?
|
|
332
|
+
//
|
|
333
|
+
// The post-condition probe lives inside this chain so an abandoned load is verified when it
|
|
334
|
+
// lands rather than recording a success nobody checked (#396). The cost, raised in review, is
|
|
335
|
+
// that a LATE landing runs that probe with no lock held, against the single SQLite connection,
|
|
336
|
+
// while another session may be mid-operation inside the lock.
|
|
337
|
+
//
|
|
338
|
+
// The resolution is not to probe on that path at all. A landing whose caller has already given
|
|
339
|
+
// up leaves the record INDETERMINATE, which forces the next operation to re-select. That is the
|
|
340
|
+
// safe direction #390 established, it costs one extra re-selection after an event that is rare
|
|
341
|
+
// by construction (a load that outran its bound), and it removes the unsynchronised read
|
|
342
|
+
// entirely rather than arguing about whether it is observable.
|
|
343
|
+
let abandoned = false;
|
|
331
344
|
const tracked = raw
|
|
332
345
|
.then(async (result) => {
|
|
346
|
+
if (abandoned) {
|
|
347
|
+
log.warn('an abandoned budget load landed after its caller gave up; leaving the record indeterminate', {
|
|
348
|
+
label,
|
|
349
|
+
});
|
|
350
|
+
// The load itself SUCCEEDED upstream, so any cached failure reason for this budget is
|
|
351
|
+
// stale even though we are not recording the syncId. Clearing here keeps the hook's stated
|
|
352
|
+
// contract true on the abandoned path as well (see the note on `onRecorded` below).
|
|
353
|
+
try {
|
|
354
|
+
onRecorded?.();
|
|
355
|
+
}
|
|
356
|
+
catch { /* a hook cannot change the outcome */ }
|
|
357
|
+
return result;
|
|
358
|
+
}
|
|
333
359
|
if (verify)
|
|
334
360
|
await verify();
|
|
361
|
+
// #403 review: re-check. `abandoned` can flip DURING `verify()`, when the load resolves just
|
|
362
|
+
// before its bound expires. Without this the probe runs and the record is written on a load
|
|
363
|
+
// whose caller has gone, which contradicts the contract stated above. The value written
|
|
364
|
+
// would be true rather than false, so this is about honesty of the invariant rather than
|
|
365
|
+
// safety, but a contract the code only honours on the common path is not a contract.
|
|
366
|
+
if (abandoned) {
|
|
367
|
+
log.warn('a budget load was abandoned while its post-condition ran; leaving the record indeterminate', { label });
|
|
368
|
+
try {
|
|
369
|
+
onRecorded?.();
|
|
370
|
+
}
|
|
371
|
+
catch { /* a hook cannot change the outcome */ }
|
|
372
|
+
return result;
|
|
373
|
+
}
|
|
335
374
|
setLoadedBudgetSyncId(describeLoaded(result));
|
|
336
375
|
// #392 review (finding 5): this runs INSIDE the chain, not after the awaited call. On the
|
|
337
376
|
// abandoned-then-eventually-successful path the caller has already thrown, so a hook placed
|
|
@@ -357,9 +396,18 @@ async function trackBudgetMutation(start, describeLoaded, label, verify, onRecor
|
|
|
357
396
|
throw err;
|
|
358
397
|
});
|
|
359
398
|
registerBudgetLoad(tracked);
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
399
|
+
try {
|
|
400
|
+
// On timeout the tracked promise is still running. Leave it REGISTERED: the next operation
|
|
401
|
+
// waits for it inside the lock rather than racing it.
|
|
402
|
+
return await withOpTimeout(() => tracked, label, timeoutMs);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
// #403: from here the caller has given up, so anything still in flight must not touch the api
|
|
406
|
+
// with no lock held. The chain checks this before probing. Set on ANY rejection, which is
|
|
407
|
+
// harmless when the chain itself already failed: it has settled, so nothing reads the flag.
|
|
408
|
+
abandoned = true;
|
|
409
|
+
throw err;
|
|
410
|
+
}
|
|
363
411
|
}
|
|
364
412
|
/**
|
|
365
413
|
* #394: the same discipline for an IMPORT.
|
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.
|
|
4
|
+
"version": "0.16.8",
|
|
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/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/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",
|