actual-mcp-server 0.16.2 → 0.16.5
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/actualConnection.js +36 -13
- package/dist/src/lib/ActualConnectionPool.js +70 -42
- package/dist/src/lib/actual-adapter.js +170 -27
- package/dist/src/lib/apiLock.js +60 -0
- package/dist/src/lib/apiState.js +107 -0
- package/dist/src/lib/budgetLoader.js +295 -0
- 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.5 | **Tool Count:** 74 (verified LibreChat-compatible)
|
|
778
778
|
|
|
779
779
|
## Sponsor
|
|
780
780
|
|
|
@@ -2,6 +2,9 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import api from '@actual-app/api';
|
|
5
|
+
import { loadBudgetTracked } from './lib/budgetLoader.js';
|
|
6
|
+
import { withApiLock } from './lib/apiLock.js';
|
|
7
|
+
import { withOpTimeout } from './lib/opTimeout.js';
|
|
5
8
|
import logger from './logger.js';
|
|
6
9
|
import config from './config.js';
|
|
7
10
|
import { connectionPool } from './lib/ActualConnectionPool.js';
|
|
@@ -24,6 +27,11 @@ export async function connectToActual() {
|
|
|
24
27
|
return;
|
|
25
28
|
}
|
|
26
29
|
initializing = true;
|
|
30
|
+
// #396: reset before each attempt. It was set on failure and NEVER cleared, so once the
|
|
31
|
+
// silent-success bug is fixed and a bad load throws routinely, a single failure would poison
|
|
32
|
+
// every later session open for the process lifetime: a concurrent caller arriving while
|
|
33
|
+
// `initializing` is true rethrows whatever was left here.
|
|
34
|
+
initializationError = null;
|
|
27
35
|
try {
|
|
28
36
|
const SERVER_URL = config.ACTUAL_SERVER_URL;
|
|
29
37
|
const PASSWORD = config.ACTUAL_PASSWORD;
|
|
@@ -36,20 +44,35 @@ export async function connectToActual() {
|
|
|
36
44
|
if (!fs.existsSync(DATA_DIR))
|
|
37
45
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
38
46
|
log.info(`Initializing Actual API with dataDir=${DATA_DIR}`);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
// #396: this is the SIXTH budget load site, and it is production reachable. Revision 0 of
|
|
48
|
+
// that ticket claimed it was `--test-actual-connection` only; it is not.
|
|
49
|
+
// `connectToActualForSession` below falls through to this function whenever
|
|
50
|
+
// USE_CONNECTION_POOL=false (a documented, supported variable), and httpServer calls it on
|
|
51
|
+
// every HTTP session open. It used to hand-write the download and record the loaded syncId
|
|
52
|
+
// UNCONDITIONALLY, so it had none of the guarantees the other five sites have.
|
|
53
|
+
//
|
|
54
|
+
// The lock is acquired HERE, around init plus load only, mirroring
|
|
55
|
+
// ActualConnectionPool.getConnection. Never around the `while (initializing)` spin-wait
|
|
56
|
+
// above: that would hold the mutex while waiting for its holder and deadlock. No caller of
|
|
57
|
+
// this function holds the api lock, so this acquisition is safe.
|
|
58
|
+
//
|
|
59
|
+
// This is NOT behaviour neutral, and that is intended: the download becomes bounded by
|
|
60
|
+
// ACTUAL_OP_TIMEOUT_MS with abandon-not-cancel semantics, and becomes a registered load that
|
|
61
|
+
// later lock acquisitions wait for. Both match what the pool sites already do.
|
|
62
|
+
await withApiLock(async () => {
|
|
63
|
+
// #396 review: BOUND the init. It was unbounded here before, which was survivable only
|
|
64
|
+
// because this call held no lock. Now that it does, an unbounded stalled login would never
|
|
65
|
+
// settle, `.finally(release)` would never run, and the process-global api mutex would be
|
|
66
|
+
// wedged permanently for every session: the exact #270 class. Both pool sites already bound
|
|
67
|
+
// their `api.init` this way, and this was the only `api.init` in the repo that did not.
|
|
68
|
+
await withOpTimeout(() => api.init({
|
|
69
|
+
dataDir: DATA_DIR,
|
|
70
|
+
serverURL: SERVER_URL,
|
|
71
|
+
password: PASSWORD,
|
|
72
|
+
}), 'connectToActual init');
|
|
73
|
+
log.info(`Downloading budget with sync ID: ${BUDGET_SYNC_ID}`);
|
|
74
|
+
await loadBudgetTracked(BUDGET_SYNC_ID, BUDGET_PASSWORD);
|
|
43
75
|
});
|
|
44
|
-
log.info(`Downloading budget with sync ID: ${BUDGET_SYNC_ID}`);
|
|
45
|
-
// According to official docs, downloadBudget accepts optional second parameter for E2E encryption
|
|
46
|
-
if (BUDGET_PASSWORD) {
|
|
47
|
-
const apiWithOptions = api;
|
|
48
|
-
await apiWithOptions.downloadBudget(BUDGET_SYNC_ID, { password: BUDGET_PASSWORD });
|
|
49
|
-
}
|
|
50
|
-
else {
|
|
51
|
-
await api.downloadBudget(BUDGET_SYNC_ID);
|
|
52
|
-
}
|
|
53
76
|
if (TEST_ACTUAL_CONNECTION) {
|
|
54
77
|
logger.info('Test flag detected (--test-actual-connection) — closing Actual session.');
|
|
55
78
|
// Prefer the documented shutdown method only
|
|
@@ -17,6 +17,8 @@ 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';
|
|
21
|
+
import { loadBudgetTracked } from './budgetLoader.js';
|
|
20
22
|
import { withOpTimeout } from './opTimeout.js';
|
|
21
23
|
const DEFAULT_DATA_DIR = path.resolve(os.homedir() || '.', '.actual');
|
|
22
24
|
class ActualConnectionPool {
|
|
@@ -237,27 +239,40 @@ class ActualConnectionPool {
|
|
|
237
239
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
238
240
|
}
|
|
239
241
|
try {
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
242
|
+
// #390 round 2: hold the api mutex across init + download.
|
|
243
|
+
//
|
|
244
|
+
// Recording the loaded budget and checking it inside the lock only NARROWED the race
|
|
245
|
+
// while this mutator stayed outside it. Reproduced: a pooled read passed the
|
|
246
|
+
// precondition, parked at an await still holding the lock, a new session opened here on
|
|
247
|
+
// a different budget, and the first session's raw call then executed against it. The
|
|
248
|
+
// window is not small: it is the whole duration of an operation, and since #378 made the
|
|
249
|
+
// drain sequential, the whole duration of a batch, with one check at the start.
|
|
250
|
+
//
|
|
251
|
+
// Safe against the non-reentrant mutex: the two callers of getConnection
|
|
252
|
+
// (actualConnection's session-open helper and switchBudget's slow path) are both OUTSIDE
|
|
253
|
+
// any withApiLock block. switchBudget's own downloadBudget takes the lock separately and
|
|
254
|
+
// has already released it before it reaches here.
|
|
255
|
+
await withApiLock(async () => {
|
|
256
|
+
// Bound the session-open init/download (#270): this is the HTTP exposure.
|
|
257
|
+
// A stalled upstream here would otherwise hang session open unbounded and,
|
|
258
|
+
// because the api singleton is process-global, wedge other sessions too.
|
|
259
|
+
await withOpTimeout(() => api.init({
|
|
260
|
+
dataDir: DATA_DIR,
|
|
261
|
+
serverURL: SERVER_URL,
|
|
262
|
+
password: PASSWORD,
|
|
263
|
+
}), 'pool init');
|
|
264
|
+
logger.info(`[ConnectionPool] Downloading budget for session: ${sessionId}`);
|
|
265
|
+
// #390: the shared tracked loader, not a hand-written download. It clears the record
|
|
266
|
+
// BEFORE starting and keeps an abandoned load registered, so a session-open download that
|
|
267
|
+
// outruns ACTUAL_OP_TIMEOUT_MS cannot silently re-point the singleton later.
|
|
268
|
+
await loadBudgetTracked(BUDGET_SYNC_ID, BUDGET_PASSWORD, 'pool downloadBudget');
|
|
269
|
+
// Mark the singleton as live only AFTER the budget is downloaded (#270):
|
|
270
|
+
// setting it before downloadBudget left a poisoning window where the
|
|
271
|
+
// adapter's pool-cooperation branch could reuse a connection whose budget
|
|
272
|
+
// never loaded (a stalled/failed download would leak isApiInitialized=true).
|
|
273
|
+
setApiInitialized(true);
|
|
274
|
+
}); // #390: end of the api-mutex block. Everything after this is pool bookkeeping and
|
|
275
|
+
// touches no singleton state, so it does not need the lock.
|
|
261
276
|
conn = {
|
|
262
277
|
sessionId,
|
|
263
278
|
initialized: true,
|
|
@@ -276,17 +291,29 @@ class ActualConnectionPool {
|
|
|
276
291
|
catch (err) {
|
|
277
292
|
logger.error(`[ConnectionPool] Failed to initialize connection for session ${sessionId}:`, err);
|
|
278
293
|
// Clean up the failed connection attempt
|
|
279
|
-
// Try to shutdown the API to leave it in a clean state for the next attempt
|
|
280
|
-
|
|
294
|
+
// Try to shutdown the API to leave it in a clean state for the next attempt.
|
|
295
|
+
//
|
|
296
|
+
// #390 round 3: UNDER THE MUTEX, like the init above it. Locking the mutator and leaving
|
|
297
|
+
// its compensating mutator unlocked closes nothing: reproduced as this cleanup's
|
|
298
|
+
// api.shutdown() firing while ANOTHER session's operation was in flight inside the lock,
|
|
299
|
+
// so that operation observed a torn-down api. The lock is re-acquired rather than held
|
|
300
|
+
// across the whole try, because the init block released it on the way out and this
|
|
301
|
+
// function is never called from inside a lock (both callers are outside one).
|
|
302
|
+
// #393 review: the lock ITSELF can reject now (it settles an abandoned budget load on
|
|
303
|
+
// acquisition), and this is a CLEANUP path. If that rejection escaped, `setApiInitialized(false)`
|
|
304
|
+
// and the connections.delete below would be skipped and the caller would receive
|
|
305
|
+
// "abandoned budget load timed out" instead of the real session-open failure. Cleanup
|
|
306
|
+
// must not be able to fail louder than the thing it is cleaning up after, so the whole
|
|
307
|
+
// acquisition is swallowed here, not just the shutdown call.
|
|
308
|
+
await withApiLock(async () => {
|
|
281
309
|
const maybeApi = api;
|
|
282
310
|
if (typeof maybeApi.shutdown === 'function') {
|
|
283
311
|
await maybeApi.shutdown();
|
|
284
312
|
logger.debug(`[ConnectionPool] Cleaned up failed connection for session: ${sessionId}`);
|
|
285
313
|
}
|
|
286
|
-
}
|
|
287
|
-
catch (cleanupErr) {
|
|
314
|
+
}).catch((cleanupErr) => {
|
|
288
315
|
logger.debug(`[ConnectionPool] Error during cleanup (ignoring): ${cleanupErr}`);
|
|
289
|
-
}
|
|
316
|
+
});
|
|
290
317
|
// Singleton is back to torn-down state regardless of cleanup outcome.
|
|
291
318
|
setApiInitialized(false);
|
|
292
319
|
// Ensure this session is not in the connections map
|
|
@@ -312,22 +339,23 @@ class ActualConnectionPool {
|
|
|
312
339
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
313
340
|
}
|
|
314
341
|
try {
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
342
|
+
// #390 round 2: same reasoning as getConnection above; this mutates the same
|
|
343
|
+
// process-global singleton, so it takes the same mutex.
|
|
344
|
+
await withApiLock(async () => {
|
|
345
|
+
// Bound the session-open init/download (#270), same as getConnection.
|
|
346
|
+
await withOpTimeout(() => api.init({
|
|
347
|
+
dataDir: DATA_DIR,
|
|
348
|
+
serverURL: SERVER_URL,
|
|
349
|
+
password: PASSWORD,
|
|
350
|
+
}), 'pool init');
|
|
351
|
+
// #390: the shared tracked loader, not a hand-written download. It clears the record
|
|
352
|
+
// BEFORE starting and keeps an abandoned load registered, so a session-open download that
|
|
353
|
+
// outruns ACTUAL_OP_TIMEOUT_MS cannot silently re-point the singleton later.
|
|
354
|
+
await loadBudgetTracked(BUDGET_SYNC_ID, BUDGET_PASSWORD, 'pool downloadBudget');
|
|
355
|
+
// Mark live only after a successful download (#270): avoids the poisoning
|
|
356
|
+
// window where a stalled/failed download would leak isApiInitialized=true.
|
|
357
|
+
setApiInitialized(true);
|
|
358
|
+
}); // #390: end of the api-mutex block.
|
|
331
359
|
this.sharedConnection = {
|
|
332
360
|
sessionId: 'shared',
|
|
333
361
|
initialized: true,
|
|
@@ -21,7 +21,9 @@ import { parseBudgetRegistry } from './budget-registry.js';
|
|
|
21
21
|
import { getPreferredBudgetSyncId, setPreferredBudgetSyncId, pickAllowedPreferredBudget } from './budget-preference-store.js';
|
|
22
22
|
import { requestContext } from './requestContext.js';
|
|
23
23
|
import { connectionPool } from './ActualConnectionPool.js';
|
|
24
|
-
import { isApiInitialized, setApiInitialized } from './apiState.js';
|
|
24
|
+
import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, setLoadedBudgetSyncId } from './apiState.js';
|
|
25
|
+
import { withApiLock } from './apiLock.js';
|
|
26
|
+
import { loadBudgetTracked } from './budgetLoader.js';
|
|
25
27
|
/**
|
|
26
28
|
* Budget registry — all budgets configured via ACTUAL_* and BUDGET_n_* env vars.
|
|
27
29
|
* Built once at startup; used by every withActualApi call.
|
|
@@ -86,12 +88,90 @@ function getActiveBudgetConfig() {
|
|
|
86
88
|
* init/shutdown pairs corrupt the session. All callers (reads via withActualApi,
|
|
87
89
|
* writes via processWriteQueue) must acquire this lock before touching the API.
|
|
88
90
|
*/
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
/**
|
|
92
|
+
* #390: make the loaded budget a CHECKED PRECONDITION of an operation rather than a side
|
|
93
|
+
* effect of whoever opened a session last.
|
|
94
|
+
*
|
|
95
|
+
* MUST be called inside the api lock. Outside it, another session can change the loaded budget
|
|
96
|
+
* between this check and the operation, which is the race this closes rather than narrows.
|
|
97
|
+
*
|
|
98
|
+
* The bug it fixes: `@actual-app/api` is process-global with ONE loaded budget, and both
|
|
99
|
+
* re-entry paths skip the download for good reasons of their own (`getConnection` returns
|
|
100
|
+
* early for an initialised entry; `initActualApiForOperation` returns early when the singleton
|
|
101
|
+
* is live, which is #134's fix for the #127 auth burst). Together they meant a session operated
|
|
102
|
+
* on whatever budget was loaded last, by anyone. Reproduced: session A opened on budget A and
|
|
103
|
+
* wrote to it, session B opened and switched to budget B, and session A's NEXT write landed in
|
|
104
|
+
* budget B.
|
|
105
|
+
*
|
|
106
|
+
* The budget ACL could not see this: `_enforceBudgetAcl` validates the budget the session
|
|
107
|
+
* BELIEVES it is on, while the operation executes against whatever is loaded, so the check and
|
|
108
|
+
* the effect were on different budgets.
|
|
109
|
+
*
|
|
110
|
+
* Cost in the common case is one string comparison. A single-budget deployment always resolves
|
|
111
|
+
* the same syncId, so nothing extra is downloaded and no upstream call is added.
|
|
112
|
+
*/
|
|
113
|
+
/**
|
|
114
|
+
* #390: make the loaded budget a CHECKED PRECONDITION of an operation rather than a side
|
|
115
|
+
* effect of whoever opened a session last.
|
|
116
|
+
*
|
|
117
|
+
* MUST be called inside the api lock. Outside it, another session can change the loaded budget
|
|
118
|
+
* between this check and the operation.
|
|
119
|
+
*
|
|
120
|
+
* The bug: `@actual-app/api` is process-global with ONE loaded budget, and both re-entry paths
|
|
121
|
+
* skip the download for good reasons of their own (`getConnection` returns early for an
|
|
122
|
+
* initialised entry; `initActualApiForOperation` returns early when the singleton is live,
|
|
123
|
+
* which is #134's fix for the #127 auth burst). Together they meant a session operated on
|
|
124
|
+
* whatever budget was loaded last, by anyone. The budget ACL could not see it: it validates the
|
|
125
|
+
* budget the session BELIEVES it is on while the operation executes against whatever is loaded.
|
|
126
|
+
*
|
|
127
|
+
* Cost in the common case is one string comparison; a single-budget deployment always resolves
|
|
128
|
+
* the same syncId and downloads nothing. The multi-budget cost is real and is tracked as #391.
|
|
129
|
+
*/
|
|
130
|
+
async function ensureLoadedBudgetMatchesSession() {
|
|
131
|
+
if (_skipApiInitForTests)
|
|
132
|
+
return;
|
|
133
|
+
// #393: no wait here any more. Settling an abandoned load is part of acquiring the api lock,
|
|
134
|
+
// and both callers of this function are already inside it. Guarding per call site is what
|
|
135
|
+
// produced the two P0s in #393; see withApiLock for why it moved.
|
|
136
|
+
if (!isApiInitialized()) {
|
|
137
|
+
// "The init path will download correctly" is true for the LEGACY branch and false for the
|
|
138
|
+
// POOLED one, which skips init entirely by design (#134). Returning here let a pooled
|
|
139
|
+
// operation run against a singleton nobody had loaded for it, which is how the abandoned-
|
|
140
|
+
// load test caught this: session B's failed session-open poisoned the singleton, A's
|
|
141
|
+
// precondition returned early on that, and A's raw call then ran while B's abandoned
|
|
142
|
+
// download landed. Initialise explicitly instead. No recursion: initActualApiForOperation
|
|
143
|
+
// only calls back into here when the singleton IS live, and it is not.
|
|
144
|
+
await initActualApiForOperation();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const wanted = getActiveBudgetConfig();
|
|
148
|
+
const loaded = getLoadedBudgetSyncId();
|
|
149
|
+
if (loaded === wanted.syncId)
|
|
150
|
+
return;
|
|
151
|
+
// Deliberately a WARN: reaching here means two sessions are on different budgets and this
|
|
152
|
+
// one would otherwise have operated on the other's data.
|
|
153
|
+
logger.warn('[ADAPTER] loaded budget does not match this session; re-selecting before the operation', {
|
|
154
|
+
loadedSyncId: loaded ?? null,
|
|
155
|
+
wantedSyncId: wanted.syncId,
|
|
156
|
+
budget: wanted.name,
|
|
157
|
+
});
|
|
158
|
+
// Push anything pending for the budget we are about to close. Since #390 made re-selection
|
|
159
|
+
// per operation, a drain can now change budgets MID-BATCH, and the single trailing api.sync()
|
|
160
|
+
// covers only the last one. Without this, an earlier session's write is applied locally and
|
|
161
|
+
// silently waits for the next load of that budget to propagate, which an ephemeral data dir
|
|
162
|
+
// would lose entirely.
|
|
163
|
+
if (loaded) {
|
|
164
|
+
try {
|
|
165
|
+
await withOpTimeout(() => api.sync(), 'sync');
|
|
166
|
+
}
|
|
167
|
+
catch (syncErr) {
|
|
168
|
+
logger.warn('[ADAPTER] could not sync the outgoing budget before re-selecting', {
|
|
169
|
+
loadedSyncId: loaded,
|
|
170
|
+
error: syncErr instanceof Error ? syncErr.message : String(syncErr),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
await loadBudgetTracked(wanted.syncId, wanted.encryptionPassword);
|
|
95
175
|
}
|
|
96
176
|
// Per-op timeout (#270) lives in ./opTimeout.ts so ActualConnectionPool can bound
|
|
97
177
|
// its own session-open init/download without a circular import back into this
|
|
@@ -255,6 +335,9 @@ export async function withActualApi(rawOperation) {
|
|
|
255
335
|
try {
|
|
256
336
|
connectionReuseCount++;
|
|
257
337
|
logger.debug(`[ADAPTER] Reusing pool connection for session ${sessionId} (reuses=${connectionReuseCount})`);
|
|
338
|
+
// #390: verify the singleton holds THIS session's budget before the operation runs.
|
|
339
|
+
// Inside the lock, so no other session can change it in between.
|
|
340
|
+
await ensureLoadedBudgetMatchesSession();
|
|
258
341
|
return await withOpTimeout(operation);
|
|
259
342
|
}
|
|
260
343
|
catch (err) {
|
|
@@ -308,6 +391,9 @@ export async function withActualApiWrite(operation) {
|
|
|
308
391
|
try {
|
|
309
392
|
connectionReuseCount++;
|
|
310
393
|
logger.debug(`[ADAPTER] Reusing pool connection for write session ${sessionId} (reuses=${connectionReuseCount})`);
|
|
394
|
+
// #390: verify the singleton holds THIS session's budget before the operation runs.
|
|
395
|
+
// Inside the lock, so no other session can change it in between.
|
|
396
|
+
await ensureLoadedBudgetMatchesSession();
|
|
311
397
|
const result = await withOpTimeout(operation);
|
|
312
398
|
// Propagate the write to the server so other clients (and our next
|
|
313
399
|
// read) see it. Pre-#134 this happened implicitly via api.shutdown().
|
|
@@ -423,6 +509,9 @@ async function initActualApiForOperation() {
|
|
|
423
509
|
setApiInitialized(true);
|
|
424
510
|
return;
|
|
425
511
|
}
|
|
512
|
+
// #393: the wait that used to be here moved into withApiLock. "Every path to the api has to
|
|
513
|
+
// settle it" was the right conclusion and the wrong implementation: enumerating paths is what
|
|
514
|
+
// kept missing one.
|
|
426
515
|
// If the api singleton is already live (e.g. the connection pool initialised
|
|
427
516
|
// it at MCP session open), don't redundantly call api.init() again — that
|
|
428
517
|
// would trigger an extra upstream login and reintroduce the auth-burst
|
|
@@ -430,6 +519,17 @@ async function initActualApiForOperation() {
|
|
|
430
519
|
// we just join in.
|
|
431
520
|
if (isApiInitialized()) {
|
|
432
521
|
logger.debug('[ADAPTER] api already initialised; skipping redundant init');
|
|
522
|
+
// #390 round 2: skipping the init is right (that is #134's fix for the #127 auth burst),
|
|
523
|
+
// but it also skips the downloadBudget that would have selected THIS session's budget. The
|
|
524
|
+
// legacy branch therefore leaked in both directions and, unlike the pooled branch, silently:
|
|
525
|
+
// reproduced as session A receiving session B's account list with no warning at all. It is
|
|
526
|
+
// reachable in ordinary HTTP operation because httpServer deliberately keeps an MCP session
|
|
527
|
+
// serving after its pool entry is dropped, and shutdownActualApi's sync-only branch leaves
|
|
528
|
+
// the singleton live whenever any other session still holds an entry.
|
|
529
|
+
//
|
|
530
|
+
// Checking HERE covers every legacy caller in one place: both withActualApi branches,
|
|
531
|
+
// withActualApiWrite, and the drain's legacy branch.
|
|
532
|
+
await ensureLoadedBudgetMatchesSession();
|
|
433
533
|
return;
|
|
434
534
|
}
|
|
435
535
|
try {
|
|
@@ -452,14 +552,7 @@ async function initActualApiForOperation() {
|
|
|
452
552
|
// Bound downloadBudget (#270): the legacy stdio path re-downloads on every
|
|
453
553
|
// op, and a stall here was the production hang. On timeout it rejects and
|
|
454
554
|
// the mutex releases.
|
|
455
|
-
|
|
456
|
-
const encryptionPassword = budget.encryptionPassword;
|
|
457
|
-
const apiWithOptions = api;
|
|
458
|
-
await withOpTimeout(() => apiWithOptions.downloadBudget(budget.syncId, { password: encryptionPassword }), 'downloadBudget');
|
|
459
|
-
}
|
|
460
|
-
else {
|
|
461
|
-
await withOpTimeout(() => api.downloadBudget(budget.syncId), 'downloadBudget');
|
|
462
|
-
}
|
|
555
|
+
await loadBudgetTracked(budget.syncId, budget.encryptionPassword);
|
|
463
556
|
setApiInitialized(true);
|
|
464
557
|
logger.debug('[ADAPTER] Actual API initialized for operation');
|
|
465
558
|
}
|
|
@@ -734,9 +827,26 @@ async function processWriteQueue() {
|
|
|
734
827
|
//
|
|
735
828
|
// Each op keeps its own withOpTimeout (#270) and still settles individually, so one
|
|
736
829
|
// stalled or rejected operation cannot hold the api mutex or fail its siblings.
|
|
737
|
-
for (const { operation, resolve, reject, preservesListings } of batch) {
|
|
830
|
+
for (const { operation, resolve, reject, preservesListings, requestStore } of batch) {
|
|
738
831
|
try {
|
|
739
|
-
|
|
832
|
+
// #390 round 2: run each op in the context it was ENQUEUED in.
|
|
833
|
+
//
|
|
834
|
+
// The precondition was originally checked ONCE per drain, against the ambient
|
|
835
|
+
// context. That was wrong twice over. A batch can span sessions, so one check for
|
|
836
|
+
// it is not well defined; and the ambient context here is NOT empty. The
|
|
837
|
+
// long-standing comment on scheduleWriteQueueDrain claims "setTimeout strips the
|
|
838
|
+
// ALS frame", and that is simply false: ALS propagates through timers, so the
|
|
839
|
+
// drain inherits the context of whichever session most recently scheduled it,
|
|
840
|
+
// which is the last enqueuer in the debounce window and is unrelated to this
|
|
841
|
+
// operation. Reproduced: with A and B on different budgets writing in one window,
|
|
842
|
+
// A's write landed in B's budget, and in the other ordering the precondition
|
|
843
|
+
// actively re-pointed the singleton at the wrong session, making a write that had
|
|
844
|
+
// been correct wrong. Re-entering the captured store makes each op resolve its own
|
|
845
|
+
// budget, which is what #158 captured a per-entry sessionId for in the first place.
|
|
846
|
+
const result = await requestContext.run(requestStore ?? {}, async () => {
|
|
847
|
+
await ensureLoadedBudgetMatchesSession();
|
|
848
|
+
return await withOpTimeout(operation);
|
|
849
|
+
});
|
|
740
850
|
resolve(result);
|
|
741
851
|
}
|
|
742
852
|
catch (error) {
|
|
@@ -846,6 +956,31 @@ async function processWriteQueue() {
|
|
|
846
956
|
});
|
|
847
957
|
});
|
|
848
958
|
}
|
|
959
|
+
catch (lockError) {
|
|
960
|
+
// #393 review: THE LOCK ITSELF CAN NOW REJECT, before `fn()` ever runs.
|
|
961
|
+
//
|
|
962
|
+
// The batch-rejection handler above lives INSIDE the lock callback, which was sound while
|
|
963
|
+
// withApiLock could only reject from the callback. Once acquiring the lock started settling
|
|
964
|
+
// an abandoned budget load, a timeout there bypasses that handler entirely, and there was
|
|
965
|
+
// no outer catch: every queued write then never settled (its residency timer was cleared at
|
|
966
|
+
// dispatch, so nothing could rescue it, which is the #278 signature), and because
|
|
967
|
+
// processWriteQueue is invoked unawaited from scheduleWriteQueueDrain the rejection escaped
|
|
968
|
+
// as an unhandledRejection that the allowlist does not cover, so src/index.ts called
|
|
969
|
+
// process.exit(1). A stalled upstream download would have taken the whole server down and
|
|
970
|
+
// every other tenant with it.
|
|
971
|
+
//
|
|
972
|
+
// Reject the batch here instead. The operations never ran and nothing was written, so this
|
|
973
|
+
// is the same contract queueWriteOperation's residency rejection uses.
|
|
974
|
+
logger.error('[WRITE QUEUE] Could not acquire the api lock for this batch:', lockError);
|
|
975
|
+
batch.forEach(({ reject }) => {
|
|
976
|
+
try {
|
|
977
|
+
reject(lockError);
|
|
978
|
+
}
|
|
979
|
+
catch (e) {
|
|
980
|
+
logger.error('[WRITE QUEUE] Error rejecting operation:', e);
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
}
|
|
849
984
|
finally {
|
|
850
985
|
isProcessingWrites = false;
|
|
851
986
|
// #278: ALWAYS re-drain a non-empty queue. The old `&& writeSessionTimeout === null`
|
|
@@ -862,7 +997,11 @@ function queueWriteOperation(operation, options) {
|
|
|
862
997
|
// op is never enqueued and no upstream resource is touched.
|
|
863
998
|
_enforceBudgetAcl();
|
|
864
999
|
// Capture sessionId from AsyncLocalStorage at enqueue time. The setTimeout
|
|
865
|
-
// below
|
|
1000
|
+
// below does NOT strip the ALS frame (that claim was here for a long time and is false:
|
|
1001
|
+
// ALS propagates through timers). What it does is inherit the context of whichever session
|
|
1002
|
+
// most recently SCHEDULED the drain, which is the last enqueuer in the debounce window and
|
|
1003
|
+
// has nothing to do with the op being run. So capturing per entry is still exactly right,
|
|
1004
|
+
// and #390 additionally re-enters the captured store per op. Without capturing, the pool-branch
|
|
866
1005
|
// decision in processWriteQueue would always miss. See #158.
|
|
867
1006
|
const sessionId = _resolveSessionId();
|
|
868
1007
|
return new Promise((resolve, reject) => {
|
|
@@ -871,6 +1010,7 @@ function queueWriteOperation(operation, options) {
|
|
|
871
1010
|
resolve,
|
|
872
1011
|
reject,
|
|
873
1012
|
sessionId,
|
|
1013
|
+
requestStore: requestContext.getStore(),
|
|
874
1014
|
preservesListings: options?.preservesListings,
|
|
875
1015
|
};
|
|
876
1016
|
// #278: bound queue RESIDENCY, not just execution. #270's withOpTimeout bounds an
|
|
@@ -2512,14 +2652,7 @@ export async function switchBudget(name) {
|
|
|
2512
2652
|
else {
|
|
2513
2653
|
await withApiLock(async () => {
|
|
2514
2654
|
// Bound the in-place budget reload too (#270).
|
|
2515
|
-
|
|
2516
|
-
const encryptionPassword = found.encryptionPassword;
|
|
2517
|
-
const apiWithOptions = api;
|
|
2518
|
-
await withOpTimeout(() => apiWithOptions.downloadBudget(found.syncId, { password: encryptionPassword }), 'downloadBudget');
|
|
2519
|
-
}
|
|
2520
|
-
else {
|
|
2521
|
-
await withOpTimeout(() => api.downloadBudget(found.syncId), 'downloadBudget');
|
|
2522
|
-
}
|
|
2655
|
+
await loadBudgetTracked(found.syncId, found.encryptionPassword);
|
|
2523
2656
|
});
|
|
2524
2657
|
}
|
|
2525
2658
|
connectionPool.updateLoadedSyncId(sessionId, found.syncId);
|
|
@@ -2741,11 +2874,21 @@ export async function exportBudget() {
|
|
|
2741
2874
|
export async function importBudget(input, opts = {}) {
|
|
2742
2875
|
observability.incrementToolCall('actual.budgets.import').catch(() => { });
|
|
2743
2876
|
const result = await queueWriteOperation(async () => {
|
|
2744
|
-
|
|
2877
|
+
const imported = await withConcurrency(() => retry(() => rawImportBudget(input, opts), {
|
|
2745
2878
|
retries: 0,
|
|
2746
2879
|
backoffMs: 200,
|
|
2747
2880
|
isRetryable: isRetryableError,
|
|
2748
2881
|
}));
|
|
2882
|
+
// #390: an import CHANGES the loaded budget (upstream documents importBudget as "loads the
|
|
2883
|
+
// imported budget"), and the singleton's record kept naming the pre-import one. That is the
|
|
2884
|
+
// unsafe direction: the precondition compares against it, passes, and a session then reads
|
|
2885
|
+
// the imported file while believing it is on its own budget. A sentinel rather than a real
|
|
2886
|
+
// syncId, because an imported file is outside the budget registry and outside the ACL, so
|
|
2887
|
+
// no session can ever legitimately match it and every session's next operation re-selects
|
|
2888
|
+
// its own budget. Recorded AT the call site so the guard in
|
|
2889
|
+
// tests/unit/budget_selection_precondition.test.js can see it.
|
|
2890
|
+
setLoadedBudgetSyncId(`imported:${imported.id}`);
|
|
2891
|
+
return imported;
|
|
2749
2892
|
});
|
|
2750
2893
|
// #349: an import CHANGES WHICH BUDGET IS LOADED, so the pool's record of it
|
|
2751
2894
|
// must stop naming the old one.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { awaitAbandonedBudgetLoad } from './apiState.js';
|
|
2
|
+
import { withOpTimeout } from './opTimeout.js';
|
|
3
|
+
import { createModuleLogger } from './loggerFactory.js';
|
|
4
|
+
const logger = createModuleLogger('API LOCK');
|
|
5
|
+
/**
|
|
6
|
+
* The process-global @actual-app/api session mutex.
|
|
7
|
+
*
|
|
8
|
+
* WHY IT LIVES HERE rather than in actual-adapter.ts, which is where it was written and where
|
|
9
|
+
* every existing caller still reaches it from (the adapter re-exports it, so importers are
|
|
10
|
+
* unchanged). #390 needed `ActualConnectionPool` to hold this lock around its own
|
|
11
|
+
* init + downloadBudget, and the pool cannot import from the adapter: the adapter imports the
|
|
12
|
+
* pool singleton, so that edge is circular. This is the same reason `apiState.ts` and
|
|
13
|
+
* `opTimeout.ts` were split out before it, and the comments there say so.
|
|
14
|
+
*
|
|
15
|
+
* WHAT IT GUARANTEES. `@actual-app/api` is a singleton over one SQLite connection, and
|
|
16
|
+
* concurrent init/shutdown pairs corrupt the session. Every path that touches the api takes
|
|
17
|
+
* this first: reads via `withActualApi`, writes via `processWriteQueue`, and since #390 the
|
|
18
|
+
* pool's session-open init and budget download.
|
|
19
|
+
*
|
|
20
|
+
* IT IS NOT REENTRANT, and that is load bearing. It is a strict FIFO chained-promise mutex, so
|
|
21
|
+
* an inner acquisition waits on the outer call's own release promise, which cannot fire until
|
|
22
|
+
* the outer callback resolves. The observable symptom is a stall ended by #270's operation
|
|
23
|
+
* timeout, NOT a hang, and it should be read as a probable nesting bug rather than a slow
|
|
24
|
+
* upstream. Before adding an acquisition, check that no caller above you already holds it.
|
|
25
|
+
*/
|
|
26
|
+
let _apiSessionLock = Promise.resolve();
|
|
27
|
+
/**
|
|
28
|
+
* #393: settling an abandoned budget load is part of ACQUIRING the lock, not something call
|
|
29
|
+
* sites remember to do.
|
|
30
|
+
*
|
|
31
|
+
* Every previous round of #390 guarded per call site and every round missed one. Round 1 missed
|
|
32
|
+
* the legacy path (a silent cross-tenant read). Round 2 put the wait in the two adapter entry
|
|
33
|
+
* points and missed the three `loadBudgetTracked` sites that reach the api without passing
|
|
34
|
+
* through them, so a session opening during the window untracked the abandoned load and the
|
|
35
|
+
* leak stayed reachable. CLAUDE.md already names this hazard class for `withOpTimeout`: the
|
|
36
|
+
* protection is per-call-site rather than structural, so a new acquisition site added without
|
|
37
|
+
* it fails silently.
|
|
38
|
+
*
|
|
39
|
+
* Putting it here makes the set of call sites stop mattering. Nothing reaches
|
|
40
|
+
* `@actual-app/api` without this lock, so nothing reaches it without the wait.
|
|
41
|
+
*
|
|
42
|
+
* It is bounded and fails closed (see `awaitAbandonedBudgetLoad`): an unbounded wait here would
|
|
43
|
+
* wedge every session on one stuck download, which is what made the round-2 version worse than
|
|
44
|
+
* the bug it fixed.
|
|
45
|
+
*/
|
|
46
|
+
export function withApiLock(fn) {
|
|
47
|
+
let release;
|
|
48
|
+
const prevLock = _apiSessionLock;
|
|
49
|
+
_apiSessionLock = new Promise((resolve) => {
|
|
50
|
+
release = resolve;
|
|
51
|
+
});
|
|
52
|
+
return prevLock
|
|
53
|
+
.then(async () => {
|
|
54
|
+
if (await awaitAbandonedBudgetLoad(withOpTimeout)) {
|
|
55
|
+
logger.warn('[API LOCK] waited for an abandoned budget load before running this operation');
|
|
56
|
+
}
|
|
57
|
+
return await fn();
|
|
58
|
+
})
|
|
59
|
+
.finally(() => release());
|
|
60
|
+
}
|
package/dist/src/lib/apiState.js
CHANGED
|
@@ -23,4 +23,111 @@ export function isApiInitialized() {
|
|
|
23
23
|
}
|
|
24
24
|
export function setApiInitialized(value) {
|
|
25
25
|
_apiInitialized = value;
|
|
26
|
+
// A singleton that is not live holds no budget. Clearing here means a shutdown can never
|
|
27
|
+
// leave a stale claim behind for the next caller to trust.
|
|
28
|
+
if (!value)
|
|
29
|
+
_loadedBudgetSyncId = null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* #390: the syncId of the budget the SINGLETON currently holds, as distinct from the budget
|
|
33
|
+
* any given session believes it is on.
|
|
34
|
+
*
|
|
35
|
+
* WHY THIS HAS TO EXIST. `@actual-app/api` is process-global with ONE loaded budget, while the
|
|
36
|
+
* pool tracks up to MAX_CONCURRENT_SESSIONS entries that each carry their own syncId. Before
|
|
37
|
+
* this, nothing recorded which budget was actually loaded, so no code could tell that a
|
|
38
|
+
* session was about to operate on someone else's. Both re-entry paths skip the download for
|
|
39
|
+
* good reasons of their own: `getConnection` returns early for an initialised entry, and
|
|
40
|
+
* `initActualApiForOperation` returns early when the singleton is live (which is #134's fix
|
|
41
|
+
* for the #127 auth burst). Neither is wrong; together they meant the loaded budget was
|
|
42
|
+
* whatever the last session to open had asked for.
|
|
43
|
+
*
|
|
44
|
+
* Reproduced before the fix: session A opened on budget A and wrote to it, session B opened
|
|
45
|
+
* and switched to budget B, and session A's NEXT write landed in budget B. The budget ACL
|
|
46
|
+
* could not see it, because `_enforceBudgetAcl` validates the budget the session believes it
|
|
47
|
+
* is on while the operation executes against whatever is loaded.
|
|
48
|
+
*
|
|
49
|
+
* Written by every path that downloads a budget; read inside the api lock by the adapter's
|
|
50
|
+
* precondition check. Lives here for the same reason the live flag does: the pool must update
|
|
51
|
+
* it without importing the adapter.
|
|
52
|
+
*/
|
|
53
|
+
let _loadedBudgetSyncId = null;
|
|
54
|
+
export function getLoadedBudgetSyncId() {
|
|
55
|
+
return _loadedBudgetSyncId;
|
|
56
|
+
}
|
|
57
|
+
export function setLoadedBudgetSyncId(syncId) {
|
|
58
|
+
_loadedBudgetSyncId = syncId;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* #390 round 3: an ABANDONED budget load.
|
|
62
|
+
*
|
|
63
|
+
* `withOpTimeout` races; it does not cancel. When a `downloadBudget` exceeds
|
|
64
|
+
* ACTUAL_OP_TIMEOUT_MS the underlying call KEEPS RUNNING and eventually re-points the
|
|
65
|
+
* singleton, outside the mutex, at a moment nobody is waiting for. Upstream makes this worse
|
|
66
|
+
* than a failed no-op: `handlers['api/download-budget']` begins with `close-budget`, then
|
|
67
|
+
* `load-budget` and `sync-budget`, so an abandoned download CLOSES whatever budget is loaded
|
|
68
|
+
* and opens a different one mid-flight, underneath another session's lock.
|
|
69
|
+
*
|
|
70
|
+
* Two leaks were reproduced against the previous fix. The record stayed on the old budget
|
|
71
|
+
* (only set on success), so the next matching session passed the check and read someone else's
|
|
72
|
+
* data; and even after recording the true outcome, the re-point landed BETWEEN a session's
|
|
73
|
+
* check and its raw call, so the write still went to the wrong budget. A mutex cannot serialise
|
|
74
|
+
* a promise its holder has abandoned.
|
|
75
|
+
*
|
|
76
|
+
* So the load is tracked rather than merely bounded. The record is cleared BEFORE a download
|
|
77
|
+
* starts, so an abandonment can only leave it indeterminate (the safe direction, which forces a
|
|
78
|
+
* re-select), and the abandoned promise stays registered here until it settles. No operation
|
|
79
|
+
* may proceed past a pending abandoned load: `awaitAbandonedBudgetLoad` is awaited inside the
|
|
80
|
+
* api lock, so a late landing happens BEFORE the next check rather than between a check and its
|
|
81
|
+
* use.
|
|
82
|
+
*/
|
|
83
|
+
const _pendingBudgetLoads = new Set();
|
|
84
|
+
/**
|
|
85
|
+
* Register an in-flight budget load.
|
|
86
|
+
*
|
|
87
|
+
* A SET, and each entry removes itself once it settles. Two earlier shapes were wrong:
|
|
88
|
+
* assigning to a single slot let a session opening during the window overwrite an outstanding
|
|
89
|
+
* abandoned load, whose success then cleared it, so the abandoned promise became untracked and
|
|
90
|
+
* landed later (that was #393's second P0). Chaining fixed the overwrite but left a completed
|
|
91
|
+
* load registered forever, because a chained promise is no longer the handle its creator holds,
|
|
92
|
+
* so nothing could clear it and "is a load outstanding" stopped meaning anything.
|
|
93
|
+
*
|
|
94
|
+
* With a set, concurrent loads all stay tracked and a finished one drops out on its own, so
|
|
95
|
+
* what remains is exactly the loads that are still in flight.
|
|
96
|
+
*/
|
|
97
|
+
export function registerBudgetLoad(p) {
|
|
98
|
+
// Never let the registration raise an unhandled rejection: the caller races this promise and
|
|
99
|
+
// handles (or abandons) the failure on its own path.
|
|
100
|
+
const safe = p.then(() => undefined, () => undefined);
|
|
101
|
+
_pendingBudgetLoads.add(safe);
|
|
102
|
+
void safe.then(() => {
|
|
103
|
+
_pendingBudgetLoads.delete(safe);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Settle every outstanding load, BOUNDED, and FAIL CLOSED on timeout.
|
|
108
|
+
*
|
|
109
|
+
* #393: the unbounded version was a P0 worse than the leak it closed. Both call sites were
|
|
110
|
+
* inside the api mutex and outside any timeout, so ONE never-settling download blocked every
|
|
111
|
+
* session forever, with no error after the first line and no recovery short of a process
|
|
112
|
+
* restart. That is exactly the mode opTimeout.ts exists to remove and that #270 removed, so
|
|
113
|
+
* reintroducing it was a regression against a fixed bug.
|
|
114
|
+
*
|
|
115
|
+
* On timeout this THROWS and leaves the registrations in place. Both alternatives are worse:
|
|
116
|
+
* proceeding runs the operation against a singleton a landing download may re-point underneath
|
|
117
|
+
* it (the original leak), and clearing forgets the landing entirely. A persistent, legible
|
|
118
|
+
* per-request error with the mutex released each time is the honest failure for "an upstream
|
|
119
|
+
* load is stuck and we cannot cancel it".
|
|
120
|
+
*
|
|
121
|
+
* Returns true when it actually waited, so callers can log it and tests can assert it.
|
|
122
|
+
*/
|
|
123
|
+
export async function awaitAbandonedBudgetLoad(bound) {
|
|
124
|
+
if (_pendingBudgetLoads.size === 0)
|
|
125
|
+
return false;
|
|
126
|
+
const outstanding = Promise.all([..._pendingBudgetLoads]);
|
|
127
|
+
await bound(() => outstanding, 'abandoned budget load');
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
/** Test hook: is a load currently outstanding? */
|
|
131
|
+
export function _hasPendingBudgetLoadForTests() {
|
|
132
|
+
return _pendingBudgetLoads.size > 0;
|
|
26
133
|
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import api from '@actual-app/api';
|
|
2
|
+
import { withOpTimeout } from './opTimeout.js';
|
|
3
|
+
import { setApiInitialized, setLoadedBudgetSyncId, registerBudgetLoad } from './apiState.js';
|
|
4
|
+
import { createModuleLogger } from './loggerFactory.js';
|
|
5
|
+
const log = createModuleLogger('BUDGET-LOADER');
|
|
6
|
+
/**
|
|
7
|
+
* #390 round 3: the ONE way this codebase loads a budget.
|
|
8
|
+
*
|
|
9
|
+
* Every site used to hand-write `withOpTimeout(() => api.downloadBudget(...))` followed by a
|
|
10
|
+
* record-on-success. That shape is wrong in a way no amount of care fixes, because
|
|
11
|
+
* `withOpTimeout` races rather than cancels: on timeout the download keeps running and later
|
|
12
|
+
* re-points the singleton outside the mutex, and recording only on success leaves the record
|
|
13
|
+
* naming the PREVIOUS budget while the singleton holds the new one, which is the one direction
|
|
14
|
+
* that makes the precondition silently pass.
|
|
15
|
+
*
|
|
16
|
+
* So: clear the record BEFORE starting (an abandonment can then only leave it indeterminate,
|
|
17
|
+
* which forces a re-select), register the promise so a late landing is waited for rather than
|
|
18
|
+
* discovered, and record the true outcome when it settles whether or not anyone is still
|
|
19
|
+
* listening.
|
|
20
|
+
*
|
|
21
|
+
* #396 adds a POST-CONDITION, because a resolved download is not proof a budget is open.
|
|
22
|
+
* See `assertBudgetOpen` below.
|
|
23
|
+
*
|
|
24
|
+
* INVARIANT: nothing in this file may call anything that takes `withApiLock`. Every caller
|
|
25
|
+
* already holds that non-reentrant mutex, so `adapter.*` here would deadlock. It is also what
|
|
26
|
+
* makes this loader safe to call from `actualConnection.ts`, which acquires the lock itself.
|
|
27
|
+
* (#402 tracks making this structural rather than conventional.)
|
|
28
|
+
*/
|
|
29
|
+
/** The closed set of reasons upstream's `_loadBudget` can report, via `withErrorCode`. */
|
|
30
|
+
const KNOWN_LOAD_REASONS = new Set([
|
|
31
|
+
'budget-not-found',
|
|
32
|
+
'opening-budget',
|
|
33
|
+
'out-of-sync-migrations',
|
|
34
|
+
'out-of-sync-data',
|
|
35
|
+
'loading-budget',
|
|
36
|
+
]);
|
|
37
|
+
/** Upper bound on any upstream prose we echo to the client (#396 security review). */
|
|
38
|
+
const MAX_REASON_CHARS = 200;
|
|
39
|
+
/** Sentinel: the diagnostic load worked, so the failure was not deterministic. */
|
|
40
|
+
const RETRY_SUCCEEDED = 'the load succeeded on a second attempt, so this looks transient; retry the operation';
|
|
41
|
+
/** Reported when the syncId has no LOCAL copy. Deliberately says nothing about what does exist. */
|
|
42
|
+
const NO_LOCAL_COPY = 'no local copy of this budget was found';
|
|
43
|
+
/**
|
|
44
|
+
* Brand for "the download resolved but no budget is open", so the caller can tell it from an
|
|
45
|
+
* upstream stall without matching prose. Deliberately NOT a PreflightRefusal: this is an
|
|
46
|
+
* infrastructure failure, not a caller-fixable refusal (`errors.ts` taxonomy).
|
|
47
|
+
*/
|
|
48
|
+
const BUDGET_NOT_OPEN = Symbol.for('actual-mcp.budgetNotOpen');
|
|
49
|
+
function isBudgetNotOpen(err) {
|
|
50
|
+
return !!err && typeof err === 'object' && err[BUDGET_NOT_OPEN] === true;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Read `.message` off anything upstream throws.
|
|
54
|
+
*
|
|
55
|
+
* NO `instanceof Error` GUARD, deliberately. `checkFileOpen()` throws `APIError(...)`, which
|
|
56
|
+
* RETURNS A PLAIN OBJECT (`{ type: 'APIError', message, meta }`). Measured against
|
|
57
|
+
* @actual-app/api 26.8.0: `err instanceof Error` is false and `String(err)` is `[object Object]`,
|
|
58
|
+
* while `.message` works. Same precedent as `actual-adapter.ts` ("Handle Actual APIError plain
|
|
59
|
+
* objects").
|
|
60
|
+
*/
|
|
61
|
+
function messageOf(err) {
|
|
62
|
+
if (err && typeof err === 'object' && typeof err.message === 'string') {
|
|
63
|
+
return err.message;
|
|
64
|
+
}
|
|
65
|
+
return typeof err === 'string' ? err : '';
|
|
66
|
+
}
|
|
67
|
+
function codeOf(err) {
|
|
68
|
+
if (err && typeof err === 'object' && typeof err.code === 'string') {
|
|
69
|
+
return err.code;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
/** A timeout from `withOpTimeout` is an upstream STALL, never an unloadable budget. */
|
|
74
|
+
function isOpTimeout(err) {
|
|
75
|
+
return /timed out after \d+ms \(ACTUAL_OP_TIMEOUT_MS\)/.test(messageOf(err));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* #396 POST-CONDITION: is a budget actually open?
|
|
79
|
+
*
|
|
80
|
+
* `api.downloadBudget()` can RESOLVE while leaving nothing open. Upstream's
|
|
81
|
+
* `api/download-budget` discards the `{ error }` that `load-budget` returns, in BOTH branches
|
|
82
|
+
* (loot-core `src/server/api.ts:237` for a local copy, `:261` after a fresh download), and
|
|
83
|
+
* `load-budget` does not throw: it returns an error object having already called `closeBudget()`
|
|
84
|
+
* internally, which unloads prefs. The sync that follows cannot catch it either, because
|
|
85
|
+
* `_fullSync` opens with `if (... || !currentId) return []`, so with nothing loaded it reports
|
|
86
|
+
* success by returning nothing. Reproduced two ways against a real budget (a planted unknown
|
|
87
|
+
* migration id, and a `db.sqlite` that is not a database): the download resolves, nothing is
|
|
88
|
+
* open, and every later call throws `No budget file is open` permanently for that data dir.
|
|
89
|
+
*
|
|
90
|
+
* The probe is `api.getBudgetMonths()`, and it is cheap for a reason worth stating rather than
|
|
91
|
+
* assuming: it is `checkFileOpen()` plus `createAllBudgets()`, and `createAllBudgets()` already
|
|
92
|
+
* ran once inside `_loadBudget` against the SAME in-process `Spreadsheet` singleton, while
|
|
93
|
+
* `meta.createdMonths` is never persisted, so the second call filters to zero new months and does
|
|
94
|
+
* no work beyond one indexed SQLite read. Measured at 0ms with no network call.
|
|
95
|
+
*/
|
|
96
|
+
async function assertBudgetOpen() {
|
|
97
|
+
try {
|
|
98
|
+
await withOpTimeout(() => api.getBudgetMonths(), 'budget-open probe');
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
// An upstream STALL is not an unloadable budget. Propagate it unchanged so it keeps #270's
|
|
102
|
+
// semantics: diagnosing it as a version or data-dir problem would point the user at the wrong
|
|
103
|
+
// subsystem, and its message contains "timed out", which `_shouldDropPoolOnError` classes
|
|
104
|
+
// transient (correctly, for a stall).
|
|
105
|
+
if (isOpTimeout(err))
|
|
106
|
+
throw err;
|
|
107
|
+
// #396 review: classify as "not open" ONLY on the signal that actually means it. Treating
|
|
108
|
+
// EVERY probe rejection as not-open was wrong in the expensive direction: a transient
|
|
109
|
+
// getBudgetMonths failure against a perfectly healthy OPEN budget would poison the singleton,
|
|
110
|
+
// send `diagnose` off to CLOSE and reload that healthy budget, and hand the caller a
|
|
111
|
+
// permanent-sounding "no budget file is open". Anything else is a genuine failure and
|
|
112
|
+
// propagates unchanged; the enclosing `.catch` still fails closed either way.
|
|
113
|
+
if (!/no budget file is open/i.test(messageOf(err)))
|
|
114
|
+
throw err;
|
|
115
|
+
const notOpen = new Error('the budget did not load');
|
|
116
|
+
notOpen[BUDGET_NOT_OPEN] = true;
|
|
117
|
+
throw notOpen;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Strip anything path-shaped from upstream prose before it reaches an MCP client.
|
|
122
|
+
*
|
|
123
|
+
* There is NO redaction backstop on this path: `actualToolsManager.callTool` rethrows and
|
|
124
|
+
* `ActualMCPConnection.executeTool` propagates, so the message reaches the client verbatim, and
|
|
125
|
+
* `redactSecrets` is a winston format that covers log transports only. The five `getSyncError`
|
|
126
|
+
* strings interpolate just the budget UUID and are safe, but `_loadBudget` maps to that enum only
|
|
127
|
+
* for failures it CATCHES: `db.loadClock()`, `prefs.savePrefs` and `startBackupService(id)` sit
|
|
128
|
+
* outside every try/catch, so a raw fs or sqlite error carrying an ABSOLUTE PATH (and therefore a
|
|
129
|
+
* username) can reach here. Capping does not scrub a path, so scrub first.
|
|
130
|
+
*/
|
|
131
|
+
function scrubReason(prose) {
|
|
132
|
+
if (!prose)
|
|
133
|
+
return undefined;
|
|
134
|
+
// #396 review: reject on ANY separator, not just one at a word boundary. The first version
|
|
135
|
+
// required `/` at the string start or after whitespace, a quote or a paren, so
|
|
136
|
+
// `ENOENT:/home/alice/...` and `C:/Users/alice/...` both slipped through WITH the OS username,
|
|
137
|
+
// on a path that has no redaction backstop. The five known-safe upstream reasons are plain
|
|
138
|
+
// prose plus a UUID and contain none of these characters, so rejecting outright costs nothing
|
|
139
|
+
// and cannot leak.
|
|
140
|
+
if (/[/\\~]/.test(prose))
|
|
141
|
+
return undefined;
|
|
142
|
+
return prose.length > MAX_REASON_CHARS ? `${prose.slice(0, MAX_REASON_CHARS)}...` : prose;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Ask upstream WHY the load failed, on the deterministic failure path only.
|
|
146
|
+
*
|
|
147
|
+
* Upstream logs the real reason and throws it away, which is the whole reason a user hitting this
|
|
148
|
+
* has nothing actionable to go on. `api/load-budget` (unlike `api/download-budget`) DOES check the
|
|
149
|
+
* error and throws it with `withErrorCode`, so re-attempting the load through that entry point
|
|
150
|
+
* recovers the exact reason.
|
|
151
|
+
*
|
|
152
|
+
* Runs where a CALLER IS WAITING, never inside the promise `registerBudgetLoad` registers: every
|
|
153
|
+
* `withApiLock` acquisition settles all registrations under ONE bound and fails closed on timeout,
|
|
154
|
+
* so putting two network calls in there would extend the window in which unrelated sessions' lock
|
|
155
|
+
* acquisitions throw, to build a message an abandoned load has no caller to read.
|
|
156
|
+
*/
|
|
157
|
+
const reasonCache = new Map();
|
|
158
|
+
/** A load that genuinely succeeded clears any cached reason for that budget. */
|
|
159
|
+
function clearCachedReason(syncId) {
|
|
160
|
+
reasonCache.delete(syncId);
|
|
161
|
+
}
|
|
162
|
+
/** Test-only: the reason cache is module state, so a test suite must be able to reset it. */
|
|
163
|
+
export function _clearReasonCacheForTests() {
|
|
164
|
+
reasonCache.clear();
|
|
165
|
+
}
|
|
166
|
+
async function diagnose(syncId) {
|
|
167
|
+
// #396 review: memoise the reason STRING, never the fail-closed decision. Without this, every
|
|
168
|
+
// tool call while the condition persists pays two bounded network calls WHILE HOLDING the
|
|
169
|
+
// process-global api mutex, so a single unloadable budget stalls unrelated sessions for up to
|
|
170
|
+
// 2 x ACTUAL_OP_TIMEOUT_MS per call. The condition is deterministic, so the second diagnosis
|
|
171
|
+
// would report what the first did. The ticket sanctions exactly this and nothing more: the
|
|
172
|
+
// decision stays uncached, so the load is still re-attempted and still fails closed every time,
|
|
173
|
+
// and a genuinely successful load clears the entry.
|
|
174
|
+
// An empty string means "already diagnosed, and there was nothing safe to report". It must be
|
|
175
|
+
// cached like any other outcome: review round 2 found that caching only the reportable outcomes
|
|
176
|
+
// left the two most persistent failure classes re-diagnosing on every single call, which is
|
|
177
|
+
// precisely the cost this memo exists to remove.
|
|
178
|
+
const cached = reasonCache.get(syncId);
|
|
179
|
+
if (cached !== undefined) {
|
|
180
|
+
log.debug('reusing the cached load-failure reason', { syncId });
|
|
181
|
+
return cached || undefined;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const budgets = (await withOpTimeout(() => api.getBudgets(), 'diagnose getBudgets'));
|
|
185
|
+
// `api/get-budgets` does NO deduplication: it concatenates the local scan with
|
|
186
|
+
// `get-remote-files`, so a budget synced both ways appears TWICE and only the LOCAL entry
|
|
187
|
+
// carries `id`. Measured: [{"id":"_test-budget"},{"state":"remote"}] for one groupId.
|
|
188
|
+
// Matching on groupId alone can select the remote twin and call loadBudget(undefined).
|
|
189
|
+
const local = budgets.find((b) => b.groupId === syncId && typeof b.id === 'string');
|
|
190
|
+
if (!local) {
|
|
191
|
+
// Deliberately does NOT list what IS available: that would enumerate every budget on the
|
|
192
|
+
// server to a caller the ACL scopes to one.
|
|
193
|
+
reasonCache.set(syncId, NO_LOCAL_COPY);
|
|
194
|
+
return NO_LOCAL_COPY;
|
|
195
|
+
}
|
|
196
|
+
const localId = local.id;
|
|
197
|
+
// Registered like any other load: it re-enters upstream's full `_loadBudget` against the
|
|
198
|
+
// process-global singleton, so an abandoned diagnostic could otherwise land later and
|
|
199
|
+
// re-point it outside the lock (the #390 leak with a new author).
|
|
200
|
+
const p = api.loadBudget(localId);
|
|
201
|
+
registerBudgetLoad(p);
|
|
202
|
+
try {
|
|
203
|
+
await withOpTimeout(() => p, 'diagnose loadBudget');
|
|
204
|
+
// The diagnostic load SUCCEEDED, so the singleton now holds a budget nothing selected.
|
|
205
|
+
// Record indeterminate: that forces a re-select on the next operation, which is the safe
|
|
206
|
+
// direction #390 established. This line also satisfies block (4) of
|
|
207
|
+
// budget_selection_precondition.test.js structurally rather than by exemption, and it must
|
|
208
|
+
// stay in THIS brace-balanced block: the guard's window ends at the first `}` that takes
|
|
209
|
+
// depth below zero, so a setter in a sibling `catch` or `finally` arm would be outside it.
|
|
210
|
+
setLoadedBudgetSyncId(null);
|
|
211
|
+
// #396 review: the retry SUCCEEDED, so this was not a deterministic unloadable budget. Say
|
|
212
|
+
// so rather than reporting "upstream discarded the reason", which reads as permanent. The
|
|
213
|
+
// operation still fails (the record is indeterminate by design, which forces a re-select),
|
|
214
|
+
// but the caller is told that retrying is the right move. Not cached: it is not a reason,
|
|
215
|
+
// and the condition it describes is transient.
|
|
216
|
+
return RETRY_SUCCEEDED;
|
|
217
|
+
}
|
|
218
|
+
catch (loadErr) {
|
|
219
|
+
setLoadedBudgetSyncId(null);
|
|
220
|
+
// Prefer the CODE. Upstream attaches it via `withErrorCode` precisely so callers need not
|
|
221
|
+
// parse prose, and the prose is lossy: `getSyncError` collapses out-of-sync-migrations with
|
|
222
|
+
// out-of-sync-data, and opening-budget with loading-budget, so message-only capture
|
|
223
|
+
// distinguishes at most three of the five reasons.
|
|
224
|
+
const code = codeOf(loadErr);
|
|
225
|
+
log.warn('budget load diagnosis', { syncId, code: code ?? null, reason: messageOf(loadErr) });
|
|
226
|
+
const reason = code && KNOWN_LOAD_REASONS.has(code) ? code : scrubReason(messageOf(loadErr));
|
|
227
|
+
// Cache the outcome even when there is nothing reportable. A raw fs or sqlite failure has a
|
|
228
|
+
// `.code` outside KNOWN_LOAD_REASONS and path-shaped prose that `scrubReason` drops, so it
|
|
229
|
+
// yields `undefined`, and that is the ticket's own reproduced "db.sqlite is not a database"
|
|
230
|
+
// case: the one most likely to persist. Storing '' records "diagnosed" without reporting.
|
|
231
|
+
reasonCache.set(syncId, reason ?? '');
|
|
232
|
+
return reason;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch (diagErr) {
|
|
236
|
+
// A failing diagnostic is LOGGED, never substituted for the post-condition error.
|
|
237
|
+
log.warn('could not diagnose why the budget did not load', { syncId, error: messageOf(diagErr) });
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function postConditionError(syncId, reason) {
|
|
242
|
+
return new Error(`The budget "${syncId}" reported a successful download but no budget file is open, ` +
|
|
243
|
+
`so every subsequent operation would fail. ` +
|
|
244
|
+
(reason ? `Upstream reason: [${reason}]. ` : 'Upstream discarded the reason. ') +
|
|
245
|
+
`Two causes account for almost all of these: a version mismatch between @actual-app/api and ` +
|
|
246
|
+
`the Actual server or budget file (the server version guard logs a warning when it can detect ` +
|
|
247
|
+
`this), and a local budget copy that cannot be opened (check the directory configured by ` +
|
|
248
|
+
`MCP_BRIDGE_DATA_DIR, and note that two server processes sharing one data dir will do this).`);
|
|
249
|
+
}
|
|
250
|
+
export async function loadBudgetTracked(syncId, encryptionPassword, label = 'downloadBudget') {
|
|
251
|
+
const raw = encryptionPassword
|
|
252
|
+
? api
|
|
253
|
+
.downloadBudget(syncId, { password: encryptionPassword })
|
|
254
|
+
: api.downloadBudget(syncId);
|
|
255
|
+
// Indeterminate from here until it settles. Cleared first, deliberately.
|
|
256
|
+
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
|
+
const tracked = raw
|
|
265
|
+
.then(async () => {
|
|
266
|
+
await assertBudgetOpen();
|
|
267
|
+
setLoadedBudgetSyncId(syncId);
|
|
268
|
+
clearCachedReason(syncId);
|
|
269
|
+
})
|
|
270
|
+
.catch((err) => {
|
|
271
|
+
// A failed load leaves the singleton in a state nobody can describe: upstream's
|
|
272
|
+
// download handler closes the current budget before opening the new one, so "failed"
|
|
273
|
+
// does not mean "unchanged". Poison it so the next operation re-inits from scratch.
|
|
274
|
+
setApiInitialized(false);
|
|
275
|
+
throw err;
|
|
276
|
+
});
|
|
277
|
+
registerBudgetLoad(tracked);
|
|
278
|
+
try {
|
|
279
|
+
await withOpTimeout(() => tracked, label);
|
|
280
|
+
// #393 review (P3-4): NOT clearBudgetLoad(tracked). `registerBudgetLoad` registers a
|
|
281
|
+
// derived promise, not this handle, so that delete could never match and read as a working
|
|
282
|
+
// belt-and-braces path a later change might lean on. Self-removal on settle is what
|
|
283
|
+
// actually deregisters a completed load, and it has already happened by the time the
|
|
284
|
+
// awaited promise resolves here.
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
// On timeout the tracked promise is still running. Leave it REGISTERED: the next operation
|
|
288
|
+
// waits for it inside the lock rather than racing it.
|
|
289
|
+
if (isBudgetNotOpen(err)) {
|
|
290
|
+
log.error('download resolved but no budget is open', undefined, { syncId });
|
|
291
|
+
throw postConditionError(syncId, await diagnose(syncId));
|
|
292
|
+
}
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
}
|
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.5",
|
|
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_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_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",
|