actual-mcp-server 0.16.5 → 0.16.6
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 -1
- package/dist/src/lib/actual-adapter.js +45 -26
- package/dist/src/lib/budgetLoader.js +134 -30
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -774,7 +774,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
774
774
|
|
|
775
775
|
---
|
|
776
776
|
|
|
777
|
-
**Version:** 0.16.
|
|
777
|
+
**Version:** 0.16.6 | **Tool Count:** 74 (verified LibreChat-compatible)
|
|
778
778
|
|
|
779
779
|
## Sponsor
|
|
780
780
|
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -23,7 +23,7 @@ import { requestContext } from './requestContext.js';
|
|
|
23
23
|
import { connectionPool } from './ActualConnectionPool.js';
|
|
24
24
|
import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, setLoadedBudgetSyncId } 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.
|
|
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.
|
|
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.
|
|
428
|
+
await connectionPool.shutdownConnectionLocked(sessionId);
|
|
426
429
|
}
|
|
427
430
|
catch (_e) { /* swallow */ }
|
|
428
431
|
}
|
|
@@ -871,10 +874,12 @@ async function processWriteQueue() {
|
|
|
871
874
|
// the batch ran concurrently and a drain was bounded by roughly one
|
|
872
875
|
// ACTUAL_OP_TIMEOUT_MS. Sequential dispatch makes a drain's wall clock the SUM of
|
|
873
876
|
// 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
|
|
875
|
-
//
|
|
876
|
-
//
|
|
877
|
-
// singleton down
|
|
877
|
+
// (default 5) and be swept mid-drain. The sweep would then evict the session and
|
|
878
|
+
// fire its eviction listeners, closing the transport under a drain that is still
|
|
879
|
+
// running. Since #392 the sweep takes the api mutex and re-checks expiry, so it can
|
|
880
|
+
// no longer tear the singleton down mid-operation, but an unrefreshed entry is still
|
|
881
|
+
// MARKED expired, so the touch below is what keeps an active session out of the
|
|
882
|
+
// sweep's list at all.
|
|
878
883
|
//
|
|
879
884
|
// THIS MUST STAY INSIDE THE LOOP. A single touch at drain start does nothing for
|
|
880
885
|
// a drain whose wall clock is the SUM of its ops, which is the entire reason the
|
|
@@ -909,7 +914,9 @@ async function processWriteQueue() {
|
|
|
909
914
|
if (usePoolBranch && _shouldDropPoolOnError(syncError)) {
|
|
910
915
|
logger.warn(`[WRITE QUEUE] Releasing pool connection for session ${batchSessionId} after sync failure`);
|
|
911
916
|
try {
|
|
912
|
-
|
|
917
|
+
// #392: Locked variant; the drain holds withApiLock across this whole block, and
|
|
918
|
+
// the mutex is not reentrant, so the wrapping variant would deadlock here.
|
|
919
|
+
await connectionPool.shutdownConnectionLocked(batchSessionId);
|
|
913
920
|
}
|
|
914
921
|
catch (_e) {
|
|
915
922
|
/* swallow */
|
|
@@ -942,7 +949,9 @@ async function processWriteQueue() {
|
|
|
942
949
|
if (usePoolBranch) {
|
|
943
950
|
if (_shouldDropPoolOnError(error)) {
|
|
944
951
|
try {
|
|
945
|
-
|
|
952
|
+
// #392: Locked variant; the drain holds withApiLock across this whole block, and
|
|
953
|
+
// the mutex is not reentrant, so the wrapping variant would deadlock here.
|
|
954
|
+
await connectionPool.shutdownConnectionLocked(batchSessionId);
|
|
946
955
|
}
|
|
947
956
|
catch (_e) {
|
|
948
957
|
/* swallow */
|
|
@@ -2684,8 +2693,9 @@ export async function switchBudget(name) {
|
|
|
2684
2693
|
// init/shutdown cycle. A pooled entry for stdio would be created here and then
|
|
2685
2694
|
// NEVER refreshed, because connectionPool.touch() is called only from
|
|
2686
2695
|
// httpServer.ts; it would expire after SESSION_IDLE_TIMEOUT_MINUTES and
|
|
2687
|
-
// cleanupIdleConnections would
|
|
2688
|
-
//
|
|
2696
|
+
// cleanupIdleConnections would tear it down. Since #392 that teardown takes the api mutex and
|
|
2697
|
+
// re-checks expiry, so it is no longer a mid-operation hazard, but an entry that is never
|
|
2698
|
+
// refreshed still expires, and that is the reason that matters here. The
|
|
2689
2699
|
// legacy path re-reads getActiveBudgetConfig() on every call, so the switch
|
|
2690
2700
|
// still takes effect; it just costs an init per operation, which is what stdio
|
|
2691
2701
|
// already did before this ticket.
|
|
@@ -2874,20 +2884,29 @@ export async function exportBudget() {
|
|
|
2874
2884
|
export async function importBudget(input, opts = {}) {
|
|
2875
2885
|
observability.incrementToolCall('actual.budgets.import').catch(() => { });
|
|
2876
2886
|
const result = await queueWriteOperation(async () => {
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
//
|
|
2883
|
-
//
|
|
2884
|
-
//
|
|
2885
|
-
//
|
|
2886
|
-
//
|
|
2887
|
-
//
|
|
2888
|
-
//
|
|
2889
|
-
//
|
|
2890
|
-
|
|
2887
|
+
// #394: an import re-points the process-global singleton exactly as a download does, so it
|
|
2888
|
+
// gets the SAME tracking discipline, through the same helper, rather than a hand-written
|
|
2889
|
+
// approximation of it.
|
|
2890
|
+
//
|
|
2891
|
+
// What was wrong before: nothing cleared the record before the import started, nothing
|
|
2892
|
+
// registered the promise, and the sentinel was written AFTER the await, so a timeout skipped
|
|
2893
|
+
// it entirely. A large YNAB or zip import exceeding ACTUAL_OP_TIMEOUT_MS is the expected case,
|
|
2894
|
+
// not a rare one. The record therefore went on naming the PRE-IMPORT budget while the
|
|
2895
|
+
// singleton moved to an out-of-registry, un-ACL'd imported file, and a victim session passed
|
|
2896
|
+
// the #390 precondition legitimately and had its reads served from the importer's file.
|
|
2897
|
+
//
|
|
2898
|
+
// #390 still holds at this call site: `importBudgetTracked` records the `imported:` sentinel
|
|
2899
|
+
// when the promise settles, whichever way, and the clear below states the same fact at the
|
|
2900
|
+
// call site where the guard in tests/unit/budget_selection_precondition.test.js can see it.
|
|
2901
|
+
const imported = await importBudgetTracked(() => {
|
|
2902
|
+
const started = withConcurrency(() => retry(() => rawImportBudget(input, opts), {
|
|
2903
|
+
retries: 0,
|
|
2904
|
+
backoffMs: 200,
|
|
2905
|
+
isRetryable: isRetryableError,
|
|
2906
|
+
}));
|
|
2907
|
+
setLoadedBudgetSyncId(null);
|
|
2908
|
+
return started;
|
|
2909
|
+
});
|
|
2891
2910
|
return imported;
|
|
2892
2911
|
});
|
|
2893
2912
|
// #349: an import CHANGES WHICH BUDGET IS LOADED, so the pool's record of it
|
|
@@ -154,6 +154,23 @@ function scrubReason(prose) {
|
|
|
154
154
|
* so putting two network calls in there would extend the window in which unrelated sessions' lock
|
|
155
155
|
* acquisitions throw, to build a message an abandoned load has no caller to read.
|
|
156
156
|
*/
|
|
157
|
+
/**
|
|
158
|
+
* #404: the cached reason is re-derived every REDIAGNOSE_AFTER_USES failures.
|
|
159
|
+
*
|
|
160
|
+
* The cache exists because `diagnose()` runs INSIDE the process-global api mutex and makes two
|
|
161
|
+
* bounded network calls, so re-running it per failed tool call stalls unrelated sessions. But an
|
|
162
|
+
* entry was previously dropped only by a genuinely successful load or a process restart, so if a
|
|
163
|
+
* budget's failure MODE changed with no success in between, the message reported the first reason
|
|
164
|
+
* indefinitely.
|
|
165
|
+
*
|
|
166
|
+
* Bounding by USES rather than by wall-clock is deliberate: the cost this cache avoids is paid per
|
|
167
|
+
* CALL, so capping in calls bounds staleness in the same unit, needs no clock, and is
|
|
168
|
+
* deterministic under test. At 20, a persistent failure pays the diagnosis roughly 5 percent of
|
|
169
|
+
* the time (the saving the cache exists for is kept) while no operator can see a stale reason for
|
|
170
|
+
* more than 20 failed calls. The fail-closed DECISION is still never cached: every call
|
|
171
|
+
* re-attempts the load and still fails.
|
|
172
|
+
*/
|
|
173
|
+
const REDIAGNOSE_AFTER_USES = 20;
|
|
157
174
|
const reasonCache = new Map();
|
|
158
175
|
/** A load that genuinely succeeded clears any cached reason for that budget. */
|
|
159
176
|
function clearCachedReason(syncId) {
|
|
@@ -177,8 +194,18 @@ async function diagnose(syncId) {
|
|
|
177
194
|
// precisely the cost this memo exists to remove.
|
|
178
195
|
const cached = reasonCache.get(syncId);
|
|
179
196
|
if (cached !== undefined) {
|
|
180
|
-
|
|
181
|
-
|
|
197
|
+
cached.uses += 1;
|
|
198
|
+
if (cached.uses <= REDIAGNOSE_AFTER_USES) {
|
|
199
|
+
log.debug('reusing the cached load-failure reason', { syncId, uses: cached.uses });
|
|
200
|
+
return cached.reason || undefined;
|
|
201
|
+
}
|
|
202
|
+
// #404: the entry has served its budget of calls. Drop it and fall through to a fresh
|
|
203
|
+
// diagnosis, so a failure mode that changed without an intervening success is picked up.
|
|
204
|
+
reasonCache.delete(syncId);
|
|
205
|
+
log.debug('re-diagnosing the load failure after the cached reason served its budget', {
|
|
206
|
+
syncId,
|
|
207
|
+
afterUses: REDIAGNOSE_AFTER_USES,
|
|
208
|
+
});
|
|
182
209
|
}
|
|
183
210
|
try {
|
|
184
211
|
const budgets = (await withOpTimeout(() => api.getBudgets(), 'diagnose getBudgets'));
|
|
@@ -190,7 +217,7 @@ async function diagnose(syncId) {
|
|
|
190
217
|
if (!local) {
|
|
191
218
|
// Deliberately does NOT list what IS available: that would enumerate every budget on the
|
|
192
219
|
// server to a caller the ACL scopes to one.
|
|
193
|
-
reasonCache.set(syncId, NO_LOCAL_COPY);
|
|
220
|
+
reasonCache.set(syncId, { reason: NO_LOCAL_COPY, uses: 0 });
|
|
194
221
|
return NO_LOCAL_COPY;
|
|
195
222
|
}
|
|
196
223
|
const localId = local.id;
|
|
@@ -228,7 +255,7 @@ async function diagnose(syncId) {
|
|
|
228
255
|
// `.code` outside KNOWN_LOAD_REASONS and path-shaped prose that `scrubReason` drops, so it
|
|
229
256
|
// yields `undefined`, and that is the ticket's own reproduced "db.sqlite is not a database"
|
|
230
257
|
// case: the one most likely to persist. Storing '' records "diagnosed" without reporting.
|
|
231
|
-
reasonCache.set(syncId, reason ?? '');
|
|
258
|
+
reasonCache.set(syncId, { reason: reason ?? '', uses: 0 });
|
|
232
259
|
return reason;
|
|
233
260
|
}
|
|
234
261
|
}
|
|
@@ -247,45 +274,122 @@ function postConditionError(syncId, reason) {
|
|
|
247
274
|
`this), and a local budget copy that cannot be opened (check the directory configured by ` +
|
|
248
275
|
`MCP_BRIDGE_DATA_DIR, and note that two server processes sharing one data dir will do this).`);
|
|
249
276
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
277
|
+
/**
|
|
278
|
+
* The tracking discipline EVERY singleton-mutating load must follow (#390, #393, #394).
|
|
279
|
+
*
|
|
280
|
+
* Extracted so the two callers cannot drift. `downloadBudget` and `importBudget` are different
|
|
281
|
+
* upstream calls with different failure shapes, but the discipline is identical and getting it
|
|
282
|
+
* subtly different at one site is exactly how #390 stayed reachable for three rounds:
|
|
283
|
+
*
|
|
284
|
+
* 1. Clear the record BEFORE starting, so an abandonment can only leave it INDETERMINATE.
|
|
285
|
+
* That is the safe direction: it forces a re-select. Recording only on success leaves the
|
|
286
|
+
* record naming the PREVIOUS budget while the singleton moves to a new one, which is the one
|
|
287
|
+
* direction that makes the #390 precondition silently pass.
|
|
288
|
+
* 2. Register the promise, so a late landing is WAITED FOR by the next lock acquisition rather
|
|
289
|
+
* than discovered mid-operation. `withOpTimeout` races; it does not cancel.
|
|
290
|
+
* 3. Record the true outcome when it settles, whether or not anyone is still listening, and
|
|
291
|
+
* poison the singleton on failure, because upstream closes the current budget before opening
|
|
292
|
+
* the new one, so "failed" does not mean "unchanged".
|
|
293
|
+
*
|
|
294
|
+
* `verify` is #396's post-condition, and it runs BEFORE the record is written, so the record can
|
|
295
|
+
* only ever describe a mutation that actually took effect.
|
|
296
|
+
*/
|
|
297
|
+
async function trackBudgetMutation(start, describeLoaded, label, verify, onRecorded) {
|
|
298
|
+
const raw = start();
|
|
255
299
|
// Indeterminate from here until it settles. Cleared first, deliberately.
|
|
256
300
|
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.
|
|
264
301
|
const tracked = raw
|
|
265
|
-
.then(async () => {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
302
|
+
.then(async (result) => {
|
|
303
|
+
if (verify)
|
|
304
|
+
await verify();
|
|
305
|
+
setLoadedBudgetSyncId(describeLoaded(result));
|
|
306
|
+
// #392 review (finding 5): this runs INSIDE the chain, not after the awaited call. On the
|
|
307
|
+
// abandoned-then-eventually-successful path the caller has already thrown, so a hook placed
|
|
308
|
+
// after the await would never run and the cached failure reason would outlive the load that
|
|
309
|
+
// actually succeeded.
|
|
310
|
+
// Wrapped: a hook cannot be allowed to change the recorded outcome. Unwrapped, a throwing
|
|
311
|
+
// hook would be caught by the trailing .catch, reported as a load failure, and would leave
|
|
312
|
+
// the record written while setApiInitialized(false) says the singleton is dead: an
|
|
313
|
+
// incoherent pair rather than a fail-safe one.
|
|
314
|
+
try {
|
|
315
|
+
onRecorded?.();
|
|
316
|
+
}
|
|
317
|
+
catch (hookErr) {
|
|
318
|
+
log.warn('post-record hook threw; the recorded outcome stands', { error: String(hookErr) });
|
|
319
|
+
}
|
|
320
|
+
return result;
|
|
269
321
|
})
|
|
270
322
|
.catch((err) => {
|
|
271
323
|
// A failed load leaves the singleton in a state nobody can describe: upstream's
|
|
272
|
-
// download
|
|
273
|
-
// does not mean "unchanged". Poison it so the next operation re-inits
|
|
324
|
+
// download and import handlers both close the current budget before opening the new one,
|
|
325
|
+
// so "failed" does not mean "unchanged". Poison it so the next operation re-inits.
|
|
274
326
|
setApiInitialized(false);
|
|
275
327
|
throw err;
|
|
276
328
|
});
|
|
277
329
|
registerBudgetLoad(tracked);
|
|
330
|
+
// On timeout the tracked promise is still running. Leave it REGISTERED: the next operation
|
|
331
|
+
// waits for it inside the lock rather than racing it.
|
|
332
|
+
return await withOpTimeout(() => tracked, label);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* #394: the same discipline for an IMPORT.
|
|
336
|
+
*
|
|
337
|
+
* Upstream's `importBudget` LOADS the imported budget, so it re-points the process-global
|
|
338
|
+
* singleton exactly as a download does, and a large YNAB or zip import exceeding
|
|
339
|
+
* ACTUAL_OP_TIMEOUT_MS is the expected case rather than a rare one. Before this, nothing cleared
|
|
340
|
+
* the record before it started, nothing registered the promise, and the sentinel was written
|
|
341
|
+
* AFTER the await, so a timeout skipped it entirely and the record went on naming the PRE-IMPORT
|
|
342
|
+
* budget while the singleton moved to an out-of-registry, un-ACL'd file. A victim session then
|
|
343
|
+
* passed the #390 precondition legitimately and had its reads served from the importer's file.
|
|
344
|
+
*
|
|
345
|
+
* ACCEPTED COST, recorded because nothing else states it. Registering the import means every
|
|
346
|
+
* subsequent `withApiLock` acquisition waits for it, bounded, and throws on timeout. An import is
|
|
347
|
+
* not "stuck", it is legitimately long: a large YNAB or zip import can run for minutes. So while
|
|
348
|
+
* one runs past ACTUAL_OP_TIMEOUT_MS, other sessions stall for that bound and then fail with
|
|
349
|
+
* "abandoned budget load timed out", and write drains reject their batch. That is a strictly
|
|
350
|
+
* better failure than the silent cross-tenant read it replaces (a victim reading the importer's
|
|
351
|
+
* un-ACL'd file), and it is the same trade #393 accepted for downloads. It is NOT free, and the
|
|
352
|
+
* operator-facing consequence is a process-wide stall for the duration of one tenant's import.
|
|
353
|
+
* Giving imports their own larger bound is tracked separately rather than bolted on here.
|
|
354
|
+
*
|
|
355
|
+
* No `verify` here, deliberately: unlike `download-budget`, upstream's `importBudget` already
|
|
356
|
+
* asserts its own post-condition. It checks `result.error` and then `!result.id`, throwing
|
|
357
|
+
* "no budget was loaded", so it cannot resolve without having loaded something. Verified in the
|
|
358
|
+
* shipped upstream source. Adding a probe would be duplicate work on the write path.
|
|
359
|
+
*/
|
|
360
|
+
export async function importBudgetTracked(start, label = 'importBudget') {
|
|
361
|
+
// A sentinel rather than a real syncId: an imported file is outside the budget registry and
|
|
362
|
+
// outside the ACL, so no session can ever legitimately match it and every session's next
|
|
363
|
+
// operation re-selects its own budget. Configured sync ids are UUIDs, so an `imported:` prefix
|
|
364
|
+
// can never compare equal to one.
|
|
365
|
+
return trackBudgetMutation(start, (result) => `imported:${result.id}`, label);
|
|
366
|
+
}
|
|
367
|
+
export async function loadBudgetTracked(syncId, encryptionPassword, label = 'downloadBudget') {
|
|
278
368
|
try {
|
|
279
|
-
await
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
369
|
+
await trackBudgetMutation(() => {
|
|
370
|
+
const started = encryptionPassword
|
|
371
|
+
? api
|
|
372
|
+
.downloadBudget(syncId, { password: encryptionPassword })
|
|
373
|
+
: api.downloadBudget(syncId);
|
|
374
|
+
// Indeterminate from the moment the download starts. `trackBudgetMutation` clears the
|
|
375
|
+
// record too; this repeats it at the CALL SITE on purpose, AFTER the call, because block
|
|
376
|
+
// (4) of tests/unit/budget_selection_precondition.test.js scans FORWARD from the matched
|
|
377
|
+
// line to the end of its brace-balanced block and requires a record there. Satisfying
|
|
378
|
+
// that structurally, rather than exempting this file from the guard, is what keeps the
|
|
379
|
+
// #390 protection worth having.
|
|
380
|
+
setLoadedBudgetSyncId(null);
|
|
381
|
+
return started;
|
|
382
|
+
}, () => syncId, label,
|
|
383
|
+
// #396: the post-condition runs INSIDE the tracked chain, before the record is written, so
|
|
384
|
+
// an ABANDONED load is verified when it lands rather than recording a success nobody
|
|
385
|
+
// checked. The network enrichment below deliberately does NOT run there.
|
|
386
|
+
assertBudgetOpen, () => clearCachedReason(syncId));
|
|
285
387
|
}
|
|
286
388
|
catch (err) {
|
|
287
|
-
//
|
|
288
|
-
//
|
|
389
|
+
// Enrich HERE, where a caller is actually waiting. Every `withApiLock` acquisition settles
|
|
390
|
+
// all registrations under ONE bound and fails closed on timeout, so two network calls inside
|
|
391
|
+
// the tracked chain would extend the window in which unrelated sessions' lock acquisitions
|
|
392
|
+
// throw, to build a message an abandoned load has no caller to read.
|
|
289
393
|
if (isBudgetNotOpen(err)) {
|
|
290
394
|
log.error('download resolved but no budget is open', undefined, { syncId });
|
|
291
395
|
throw postConditionError(syncId, await diagnose(syncId));
|
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.6",
|
|
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",
|