actual-mcp-server 0.16.8 → 0.16.10
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 +30 -14
- package/dist/src/lib/actual-adapter/filter-ids.js +85 -0
- package/dist/src/lib/actual-adapter.js +234 -4
- package/dist/src/lib/apiLock.js +4 -2
- package/dist/src/lib/apiState.js +14 -0
- package/dist/src/lib/errors.js +11 -2
- package/dist/src/tools/bank_sync.js +8 -0
- package/dist/src/tools/transactions_filter.js +9 -0
- package/dist/src/tools/transactions_get.js +6 -7
- package/dist/src/tools/transactions_search_by_amount.js +18 -31
- package/dist/src/tools/transactions_search_by_category.js +5 -22
- package/dist/src/tools/transactions_search_by_month.js +10 -27
- package/dist/src/tools/transactions_search_by_payee.js +5 -23
- package/dist/src/tools/transactions_summary_by_category.js +5 -0
- package/dist/src/tools/transactions_summary_by_payee.js +5 -0
- package/dist/src/tools/transactions_uncategorized.js +8 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -775,7 +775,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
775
775
|
|
|
776
776
|
---
|
|
777
777
|
|
|
778
|
-
**Version:** 0.16.
|
|
778
|
+
**Version:** 0.16.10 | **Tool Count:** 74 (verified LibreChat-compatible)
|
|
779
779
|
|
|
780
780
|
## Sponsor
|
|
781
781
|
|
|
@@ -548,20 +548,36 @@ class ActualConnectionPool {
|
|
|
548
548
|
logger.info('[ConnectionPool] Force closing any stale connections from previous instance');
|
|
549
549
|
// Try to shutdown the API if it was left initialized.
|
|
550
550
|
//
|
|
551
|
-
// #411
|
|
552
|
-
// #
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
551
|
+
// #411 put this under the api mutex: it was the last api.shutdown() running without it.
|
|
552
|
+
// #416 then separated the two failures it was conflating. The body is a raw api.shutdown(),
|
|
553
|
+
// which can reject on its own, and reporting that as "could not take the api lock" sent the
|
|
554
|
+
// reader to the wrong subsystem while the retry repeated the call that had just failed.
|
|
555
|
+
//
|
|
556
|
+
// So: the guarded shutdown happens INSIDE the lock, matching every other shutdown path
|
|
557
|
+
// (which check isApiInitialized first, per #164, or a second call surfaces as
|
|
558
|
+
// "not initialized"). The catch below therefore genuinely means the ACQUISITION failed, and
|
|
559
|
+
// its fallback runs the same guarded shutdown once, unlocked and best effort.
|
|
560
|
+
const guardedShutdown = async () => {
|
|
561
|
+
if (!isApiInitialized())
|
|
562
|
+
return;
|
|
563
|
+
const maybeApi = api;
|
|
564
|
+
if (typeof maybeApi.shutdown !== 'function')
|
|
565
|
+
return;
|
|
566
|
+
try {
|
|
567
|
+
await maybeApi.shutdown();
|
|
568
|
+
logger.info('[ConnectionPool] Successfully closed stale API connection');
|
|
569
|
+
}
|
|
570
|
+
catch (shutdownErr) {
|
|
571
|
+
logger.debug(`[ConnectionPool] Stale API connection would not close (ignoring): ${shutdownErr}`);
|
|
572
|
+
}
|
|
573
|
+
finally {
|
|
574
|
+
setApiInitialized(false);
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
await withApiLock(guardedShutdown).catch(async (lockErr) => {
|
|
578
|
+
logger.debug(`[ConnectionPool] Could not take the api lock to close a stale connection: ${lockErr}`);
|
|
579
|
+
await guardedShutdown();
|
|
580
|
+
});
|
|
565
581
|
}
|
|
566
582
|
catch (err) {
|
|
567
583
|
// Ignore errors - connection may not have been initialized
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #388: what an OPTIONAL FILTER id means when the caller passes a NAME.
|
|
3
|
+
*
|
|
4
|
+
* The surface used to answer that question three different ways, and the inconsistency was the
|
|
5
|
+
* real defect rather than the schema question that surfaced it:
|
|
6
|
+
*
|
|
7
|
+
* 1. `transactions_search_by_amount` DETECTED the name, looked it up, and answered with the
|
|
8
|
+
* correct UUID. Strictly the most useful thing any of them did.
|
|
9
|
+
* 2. `transactions_get` pre-flighted and returned `notFoundMsg`, naming the entity and the
|
|
10
|
+
* listing tool but not resolving the name.
|
|
11
|
+
* 3. Everything else silently returned an EMPTY result set, which is the worst of the three:
|
|
12
|
+
* a plausible-looking wrong answer that reads as "no transactions match".
|
|
13
|
+
*
|
|
14
|
+
* #380 swept the REQUIRED lookup ids onto `CommonSchemas` and deliberately left these behind,
|
|
15
|
+
* because tightening them is a contract decision rather than a sweep. Tightening alone (making
|
|
16
|
+
* the schema reject a non-UUID) would have deleted accommodation 1, trading the best message on
|
|
17
|
+
* the surface for a ZodError. So the schema stays a bare string and the answer moves here.
|
|
18
|
+
*
|
|
19
|
+
* THE FAST PATH READS NOTHING. A value that is already a UUID is returned untouched, without a
|
|
20
|
+
* listing call, so a correct call costs exactly what it costs today. The listing read happens
|
|
21
|
+
* only on the unhappy path, where the caller has already made a mistake and the alternative is
|
|
22
|
+
* a wrong answer.
|
|
23
|
+
*
|
|
24
|
+
* This module is deliberately PURE so it can be unit-tested without an api session; the adapter
|
|
25
|
+
* owns the impure half (fetching the listing) because that is where a listing read gets `retry`
|
|
26
|
+
* and the observability call site, and because #371 and #376 concluded that a check the tools
|
|
27
|
+
* share belongs in the adapter rather than copied into ten tool files.
|
|
28
|
+
*/
|
|
29
|
+
import { UUID_PATTERN } from '../constants.js';
|
|
30
|
+
/** Human-readable entity name and the tool that lists it, for the refusal message. */
|
|
31
|
+
export const FILTER_ID_ENTITIES = {
|
|
32
|
+
account: { entity: 'Account', listTool: 'actual_accounts_list' },
|
|
33
|
+
category: { entity: 'Category', listTool: 'actual_categories_get' },
|
|
34
|
+
payee: { entity: 'Payee', listTool: 'actual_payees_get' },
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Is this value already a well-formed entity id?
|
|
38
|
+
*
|
|
39
|
+
* When it is, NOTHING else in this module runs: the value is handed straight back and no
|
|
40
|
+
* listing is read. That is what keeps the change free for every correct call, and it is also
|
|
41
|
+
* why a non-existent but well-formed UUID still behaves exactly as it does today (an empty
|
|
42
|
+
* result). Checking existence would mean a listing read on EVERY call, which is a cost this
|
|
43
|
+
* ticket explicitly declined to impose to fix a mistake nobody makes: the failure being fixed
|
|
44
|
+
* is a name passed where an id belongs, not a mistyped UUID.
|
|
45
|
+
*/
|
|
46
|
+
export function isEntityId(value) {
|
|
47
|
+
return UUID_PATTERN.test(value);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Find the row whose NAME is what the caller passed.
|
|
51
|
+
*
|
|
52
|
+
* Case-insensitive and trimmed, matching the accommodation this generalises. Returns undefined
|
|
53
|
+
* rather than throwing so the caller decides between "resolved" and "no such name", which are
|
|
54
|
+
* two different messages.
|
|
55
|
+
*
|
|
56
|
+
* Ambiguity is resolved by taking the FIRST match, deliberately. Actual permits two categories
|
|
57
|
+
* with the same name in different groups, so a name is not a key. Reporting the first is still
|
|
58
|
+
* strictly better than the empty result set this replaces, and the message names the id it
|
|
59
|
+
* chose, so a caller who meant the other one can see that it is not theirs.
|
|
60
|
+
*/
|
|
61
|
+
export function matchByName(rows, value) {
|
|
62
|
+
const wanted = value.trim().toLowerCase();
|
|
63
|
+
if (wanted === '')
|
|
64
|
+
return undefined;
|
|
65
|
+
return rows.find((r) => typeof r.name === 'string' && r.name.trim().toLowerCase() === wanted);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The refusal text for a name that DID resolve.
|
|
69
|
+
*
|
|
70
|
+
* It names the id rather than silently substituting it. Substituting would be friendlier for
|
|
71
|
+
* one call and wrong as a contract: the caller's next call would still pass the name, and a
|
|
72
|
+
* server that quietly accepts names has no way to report the ambiguity above.
|
|
73
|
+
*/
|
|
74
|
+
export function resolvedNameDetail(kind, name, id) {
|
|
75
|
+
const { entity, listTool } = FILTER_ID_ENTITIES[kind];
|
|
76
|
+
const noun = entity.toLowerCase();
|
|
77
|
+
// The article matters here only because getting it wrong ("a account") reads as a bug in the
|
|
78
|
+
// server to the person who hits this, and this string is the one they see.
|
|
79
|
+
const article = /^[aeiou]/.test(noun) ? 'an' : 'a';
|
|
80
|
+
// The ANSWER comes first, deliberately. The generic not-found prefix would otherwise put "use
|
|
81
|
+
// the listing tool" ahead of the id we already resolved, and that is the instruction a model
|
|
82
|
+
// follows.
|
|
83
|
+
return (`"${name}" is ${article} ${noun} NAME, not an id. Use "${id}" instead ` +
|
|
84
|
+
`(${listTool} lists them if that is not the ${noun} you meant).`);
|
|
85
|
+
}
|
|
@@ -21,7 +21,7 @@ import { parseBudgetRegistry } from './budget-registry.js';
|
|
|
21
21
|
import { getPreferredBudgetSyncId, setPreferredBudgetSyncId, pickAllowedPreferredBudget } from './budget-preference-store.js';
|
|
22
22
|
import { requestContext } from './requestContext.js';
|
|
23
23
|
import { connectionPool } from './ActualConnectionPool.js';
|
|
24
|
-
import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, awaitAbandonedBudgetLoad } from './apiState.js';
|
|
24
|
+
import { isApiInitialized, setApiInitialized, getLoadedBudgetSyncId, awaitAbandonedBudgetLoad, hasPendingBudgetLoad } from './apiState.js';
|
|
25
25
|
import { withApiLock } from './apiLock.js';
|
|
26
26
|
import { loadBudgetTracked, importBudgetTracked } from './budgetLoader.js';
|
|
27
27
|
/**
|
|
@@ -219,6 +219,25 @@ function _affinityBudget() {
|
|
|
219
219
|
return undefined; // no registry, no hint: FIFO is always correct
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* #417: the budget every operation in a batch resolves to, or undefined when they differ.
|
|
224
|
+
*
|
|
225
|
+
* Exported so the unanimity rule can be tested without driving the lock. NOTE the side effect,
|
|
226
|
+
* which is bounded and idempotent but worth knowing: `getActiveBudgetConfig()` memoises a restored
|
|
227
|
+
* preference into `sessionBudgetState` and may read the preference file once per un-memoised
|
|
228
|
+
* session. The operation would do exactly that anyway a moment later, so this only moves it.
|
|
229
|
+
*/
|
|
230
|
+
export function resolveBatchBudget(entries) {
|
|
231
|
+
const budgets = new Set(entries.map((e) => {
|
|
232
|
+
try {
|
|
233
|
+
return requestContext.run(e.requestStore ?? {}, () => getActiveBudgetConfig().syncId);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
}));
|
|
239
|
+
return budgets.size === 1 ? [...budgets][0] : undefined;
|
|
240
|
+
}
|
|
222
241
|
function _resolveSessionId() {
|
|
223
242
|
return requestContext.getStore()?.sessionId;
|
|
224
243
|
}
|
|
@@ -644,6 +663,26 @@ import { BANK_SYNC_SETTLE_MS, WRITE_SESSION_DELAY_MS } from './constants.js';
|
|
|
644
663
|
// and setMaxConcurrency is re-exported.
|
|
645
664
|
import { withConcurrency, setMaxConcurrency, getConcurrencySnapshot } from './actual-adapter/concurrency.js';
|
|
646
665
|
export { setMaxConcurrency };
|
|
666
|
+
/**
|
|
667
|
+
* #389: how long an entry may sit DISPATCHED BUT NOT STARTED before it is rejected.
|
|
668
|
+
*
|
|
669
|
+
* Armed only when ACTUAL_OP_TIMEOUT_MS is 0, which is the one configuration where a stalled
|
|
670
|
+
* operation strands the rest of its batch in silence. Deliberately not derived from the op
|
|
671
|
+
* timeout: that is the knob the operator turned off. Fifteen minutes clears the longest
|
|
672
|
+
* legitimate single operation (a budget import at ACTUAL_IMPORT_TIMEOUT_MS, default ten minutes)
|
|
673
|
+
* with room to spare, so a healthy slow batch is never falsely rejected, while an operation that
|
|
674
|
+
* genuinely never runs gets an error instead of silence.
|
|
675
|
+
*/
|
|
676
|
+
let STRANDED_BATCH_LIMIT_MS = 900000;
|
|
677
|
+
/**
|
|
678
|
+
* Test-only: shrink the stranded bound so the arming behaviour is testable in reasonable time.
|
|
679
|
+
*
|
|
680
|
+
* Without a seam the riskiest timer in this file could only be exercised by waiting fifteen
|
|
681
|
+
* minutes, which means in practice it would never be exercised at all.
|
|
682
|
+
*/
|
|
683
|
+
export function _setStrandedBatchLimitForTests(ms) {
|
|
684
|
+
STRANDED_BATCH_LIMIT_MS = ms;
|
|
685
|
+
}
|
|
647
686
|
let writeQueue = [];
|
|
648
687
|
let isProcessingWrites = false;
|
|
649
688
|
let writeSessionTimeout = null;
|
|
@@ -788,6 +827,68 @@ async function processWriteQueue() {
|
|
|
788
827
|
entry.residencyTimer = undefined;
|
|
789
828
|
}
|
|
790
829
|
}
|
|
830
|
+
// #389: "dispatched" stopped meaning "started" when #378 made the batch SEQUENTIAL.
|
|
831
|
+
//
|
|
832
|
+
// Before that, a hanging operation left its siblings running. Now operations k+1..N never
|
|
833
|
+
// start, and because their residency timers were cleared just above (correctly, for the
|
|
834
|
+
// concurrent model) nothing rejects them: every one of those callers waits forever and NO
|
|
835
|
+
// ERROR IS EMITTED ANYWHERE. That is #278's signature exactly, and #278 took a "flaky" E2E
|
|
836
|
+
// test to surface because the only tell was the absence of an error.
|
|
837
|
+
//
|
|
838
|
+
// It needs ACTUAL_OP_TIMEOUT_MS=0 to be reachable, which is a documented escape hatch for
|
|
839
|
+
// debugging against a slow upstream: the one situation where a stall is most likely and an
|
|
840
|
+
// operator is least likely to suspect the write queue.
|
|
841
|
+
//
|
|
842
|
+
// So each entry keeps a bound on the part that is still a QUEUE WAIT. It is cleared the
|
|
843
|
+
// moment the operation actually starts, so it can never fire on an in-flight op, and it does
|
|
844
|
+
// NOT bound the operation itself, which is what the escape hatch exists to disable.
|
|
845
|
+
//
|
|
846
|
+
// The bound cannot be ACTUAL_OP_TIMEOUT_MS, because that is exactly what the operator disabled
|
|
847
|
+
// and what makes this reachable. It is a separate, generous constant armed ONLY in that
|
|
848
|
+
// configuration: setting it to 0 asks for unbounded OPERATIONS, not for unbounded QUEUEING, and
|
|
849
|
+
// those are different promises. It must also exceed the longest LEGITIMATE batch, or it would
|
|
850
|
+
// reject work that was about to run, which the ticket names as the way to get this wrong. The
|
|
851
|
+
// longest legitimate single operation is a budget import at ACTUAL_IMPORT_TIMEOUT_MS (default
|
|
852
|
+
// 600000), so 15 minutes clears it with room and is still finite.
|
|
853
|
+
//
|
|
854
|
+
// RE-ARMED on each start, not pre-armed once (review round 1). Arming all N timers at dispatch
|
|
855
|
+
// with the same delay makes entry k's allowance "900s minus everything before it", so a batch of
|
|
856
|
+
// healthy but slow operations rejects its own tail while nothing has stalled. Four budget
|
|
857
|
+
// re-selections at four minutes each on the slow link that made the operator disable the timeout
|
|
858
|
+
// in the first place is enough. Re-arming makes the bound mean what it should: no SINGLE
|
|
859
|
+
// predecessor has been running for 15 minutes.
|
|
860
|
+
const started = new Set();
|
|
861
|
+
const strandedLimitMs = config.ACTUAL_OP_TIMEOUT_MS === 0 ? STRANDED_BATCH_LIMIT_MS : 0;
|
|
862
|
+
const armStrandedBound = (entries) => {
|
|
863
|
+
if (strandedLimitMs <= 0)
|
|
864
|
+
return;
|
|
865
|
+
for (const entry of entries) {
|
|
866
|
+
if (started.has(entry) || entry.settledEarly)
|
|
867
|
+
continue;
|
|
868
|
+
if (entry.residencyTimer)
|
|
869
|
+
clearTimeout(entry.residencyTimer);
|
|
870
|
+
entry.residencyTimer = setTimeout(() => {
|
|
871
|
+
if (started.has(entry))
|
|
872
|
+
return;
|
|
873
|
+
logger.error(`[WRITE QUEUE] Operation never started: the operation ahead of it in this batch has not ` +
|
|
874
|
+
`returned within ${strandedLimitMs}ms. Rejecting rather than stranding the caller (#389).`);
|
|
875
|
+
entry.reject(new Error('This write never started: an earlier operation in the same batch stalled. ' +
|
|
876
|
+
'The batch runs sequentially (#378), so it was queued behind that operation. Retry it.'));
|
|
877
|
+
entry.settledEarly = true;
|
|
878
|
+
}, strandedLimitMs);
|
|
879
|
+
if (typeof entry.residencyTimer.unref === 'function')
|
|
880
|
+
entry.residencyTimer.unref();
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
const clearStrandedBounds = () => {
|
|
884
|
+
for (const entry of batch) {
|
|
885
|
+
if (entry.residencyTimer) {
|
|
886
|
+
clearTimeout(entry.residencyTimer);
|
|
887
|
+
entry.residencyTimer = undefined;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
armStrandedBound(batch);
|
|
791
892
|
writeQueueBatchCount++;
|
|
792
893
|
// Pool-cooperation decision (#158): use the first queued op's captured
|
|
793
894
|
// sessionId as the batch's sessionId. In practice all ops batched together
|
|
@@ -807,6 +908,18 @@ async function processWriteQueue() {
|
|
|
807
908
|
// assignment to clear and therefore no path, fatal or otherwise, that can leak it: when
|
|
808
909
|
// the callback returns the store is gone. This replaced a module-level variable plus a
|
|
809
910
|
// `finally` that nulled it, which worked but rested on that finally being reached.
|
|
911
|
+
// #417: hint the batch's budget, but ONLY when every operation in it resolves to the same one.
|
|
912
|
+
//
|
|
913
|
+
// #391 made an unhinted waiter a BARRIER affinity may not cross, which is what stops a drain
|
|
914
|
+
// being overtaken by reads queued after it. The side effect is that an unhinted drain also
|
|
915
|
+
// blocks affinity for everything behind it: measured at 5 re-selections for an alternating load
|
|
916
|
+
// with a drain in every fourth slot, against 2 with no drains.
|
|
917
|
+
//
|
|
918
|
+
// Deliberately NOT the `batch[0]` heuristic, which CLAUDE.md already names as a hazard for the
|
|
919
|
+
// connection choice: one entry's budget says nothing about the rest, and a wrong hint would
|
|
920
|
+
// reorder a mixed batch. Unanimity or nothing. The hint is only ever an ordering preference;
|
|
921
|
+
// `ensureLoadedBudgetMatchesSession` inside the lock still decides what is safe.
|
|
922
|
+
const batchBudget = resolveBatchBudget(batch);
|
|
810
923
|
await drainListingStore.run(new Map(), async () => {
|
|
811
924
|
await withApiLock(async () => {
|
|
812
925
|
try {
|
|
@@ -851,7 +964,43 @@ async function processWriteQueue() {
|
|
|
851
964
|
//
|
|
852
965
|
// Each op keeps its own withOpTimeout (#270) and still settles individually, so one
|
|
853
966
|
// stalled or rejected operation cannot hold the api mutex or fail its siblings.
|
|
854
|
-
|
|
967
|
+
let stuckLoadError = null;
|
|
968
|
+
// Counted so the event is logged ONCE with a total. Without this the fail-fast path sits
|
|
969
|
+
// outside the per-operation try, so it never reaches the '[WRITE QUEUE] Operation failed'
|
|
970
|
+
// line and N operations fail with no log entry anywhere. Given this whole ticket family
|
|
971
|
+
// exists because absence-of-error is the failure signature, that would be the wrong silence
|
|
972
|
+
// to introduce while removing another.
|
|
973
|
+
let failedFast = 0;
|
|
974
|
+
for (const entry of batch) {
|
|
975
|
+
const { operation, resolve, reject, preservesListings, requestStore, timeoutMs } = entry;
|
|
976
|
+
// #389: this operation is STARTING, so its queue-wait bound no longer applies. Cleared
|
|
977
|
+
// before any await, so the timer can never fire on an in-flight op.
|
|
978
|
+
started.add(entry);
|
|
979
|
+
if (entry.residencyTimer) {
|
|
980
|
+
clearTimeout(entry.residencyTimer);
|
|
981
|
+
entry.residencyTimer = undefined;
|
|
982
|
+
}
|
|
983
|
+
// Restart the bound for everything still waiting, so it measures THIS operation's
|
|
984
|
+
// runtime rather than the batch's cumulative one.
|
|
985
|
+
armStrandedBound(batch);
|
|
986
|
+
if (entry.settledEarly)
|
|
987
|
+
continue; // its residency bound already rejected it
|
|
988
|
+
// #414: fail FAST while a budget load is genuinely stuck, rather than paying the
|
|
989
|
+
// abandoned-load bound once per remaining operation. A stuck load makes every one of
|
|
990
|
+
// them fail identically, so N operations would hold the process-global mutex for N
|
|
991
|
+
// times ACTUAL_OP_TIMEOUT_MS. The condition is RE-CHECKED rather than latched, because
|
|
992
|
+
// a load can land just after a bound expires and the whole point of keeping the
|
|
993
|
+
// registration is that a late landing is still waited for.
|
|
994
|
+
if (stuckLoadError !== null && hasPendingBudgetLoad()) {
|
|
995
|
+
// A DISTINCT error, not the one the earlier operation's wait produced (review round 1).
|
|
996
|
+
// Handing this operation that error claims it waited and timed out, which it never did,
|
|
997
|
+
// and its message matches TRANSIENT_ERROR_PATTERNS while describing something that is
|
|
998
|
+
// not this operation's failure. `cause` keeps the real origin attached.
|
|
999
|
+
failedFast++;
|
|
1000
|
+
reject(new Error('This write was queued behind an operation whose budget load is still outstanding, ' +
|
|
1001
|
+
'so it was failed immediately rather than waiting for the same bound. Retry it.', { cause: stuckLoadError }));
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
855
1004
|
try {
|
|
856
1005
|
// #390 round 2: run each op in the context it was ENQUEUED in.
|
|
857
1006
|
//
|
|
@@ -887,7 +1036,15 @@ async function processWriteQueue() {
|
|
|
887
1036
|
// A rejection here is caught by this loop's own per-op catch, so a stuck load fails
|
|
888
1037
|
// ONE operation rather than the whole batch, which is the behaviour #393 round 4
|
|
889
1038
|
// established for the drain.
|
|
890
|
-
|
|
1039
|
+
try {
|
|
1040
|
+
await awaitAbandonedBudgetLoad(withOpTimeout);
|
|
1041
|
+
}
|
|
1042
|
+
catch (waitErr) {
|
|
1043
|
+
// #414: remember it, so the rest of the batch fails fast instead of each paying
|
|
1044
|
+
// the same bound. Still rethrown, so THIS operation fails exactly as before.
|
|
1045
|
+
stuckLoadError = waitErr instanceof Error ? waitErr : new Error(String(waitErr));
|
|
1046
|
+
throw waitErr;
|
|
1047
|
+
}
|
|
891
1048
|
await ensureLoadedBudgetMatchesSession();
|
|
892
1049
|
// #407: an operation may carry its OWN bound. Only a budget import does today, and
|
|
893
1050
|
// it needs one: the import is legitimately long, and bounding it at the general
|
|
@@ -952,6 +1109,10 @@ async function processWriteQueue() {
|
|
|
952
1109
|
connectionPool.touch(sid);
|
|
953
1110
|
}
|
|
954
1111
|
}
|
|
1112
|
+
if (failedFast > 0) {
|
|
1113
|
+
logger.error(`[WRITE QUEUE] ${failedFast} operation(s) failed fast because a budget load is still ` +
|
|
1114
|
+
`outstanding. They were never attempted; the stalled load is the cause (#414).`);
|
|
1115
|
+
}
|
|
955
1116
|
// Explicitly sync changes to server before shutdown (legacy) or just
|
|
956
1117
|
// before returning (pool). Persistence guarantee in both branches.
|
|
957
1118
|
logger.debug(`[WRITE QUEUE] Syncing ${batch.length} operations to server`);
|
|
@@ -1015,10 +1176,19 @@ async function processWriteQueue() {
|
|
|
1015
1176
|
await shutdownActualApi();
|
|
1016
1177
|
}
|
|
1017
1178
|
}
|
|
1018
|
-
});
|
|
1179
|
+
}, { budget: batchBudget });
|
|
1019
1180
|
});
|
|
1181
|
+
clearStrandedBounds();
|
|
1020
1182
|
}
|
|
1021
1183
|
catch (lockError) {
|
|
1184
|
+
// #389 review (I2): clear the stranded bounds here too. When the ACQUISITION rejects (which it
|
|
1185
|
+
// can, since #393, on an abandoned-load timeout) the loop is never entered, so without this all
|
|
1186
|
+
// N timers stay armed and each one logs "the operation ahead of it in this batch has not
|
|
1187
|
+
// returned" fifteen minutes later, for a batch that no longer exists and whose failure was a
|
|
1188
|
+
// lock acquisition rather than a stalled sibling. The rejects are harmless no-ops on settled
|
|
1189
|
+
// promises; the false signal is the problem, and it is the same wrong-subsystem misdirection
|
|
1190
|
+
// #416 exists to remove.
|
|
1191
|
+
clearStrandedBounds();
|
|
1022
1192
|
// #393 review: THE LOCK ITSELF CAN NOW REJECT, before `fn()` ever runs.
|
|
1023
1193
|
//
|
|
1024
1194
|
// The batch-rejection handler above lives INSIDE the lock callback, which was sound while
|
|
@@ -1175,6 +1345,7 @@ export const notifications = new EventEmitter();
|
|
|
1175
1345
|
// Extracted to ./actual-adapter/normalize.ts (#166). Imported for internal use
|
|
1176
1346
|
// and re-exported so the public surface and external importers are unchanged.
|
|
1177
1347
|
import { normalizeToTransactionArray, normalizeToId, normalizeImportResult } from './actual-adapter/normalize.js';
|
|
1348
|
+
import { isEntityId, matchByName, resolvedNameDetail, FILTER_ID_ENTITIES } from './actual-adapter/filter-ids.js';
|
|
1178
1349
|
export { normalizeToTransactionArray, normalizeToId, normalizeImportResult };
|
|
1179
1350
|
// ---------------------------------------------------------------------------
|
|
1180
1351
|
export async function getAccounts() {
|
|
@@ -1354,6 +1525,64 @@ export async function getPayees() {
|
|
|
1354
1525
|
return await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
|
|
1355
1526
|
});
|
|
1356
1527
|
}
|
|
1528
|
+
/**
|
|
1529
|
+
* #388: turn an optional FILTER id that is actually a NAME into a refusal that names the id.
|
|
1530
|
+
*
|
|
1531
|
+
* Every Category B field routes through here, so the surface has ONE answer to the single most
|
|
1532
|
+
* likely caller mistake instead of the three it had (a helpful resolution in one tool, a bare
|
|
1533
|
+
* not-found in another, and a silent empty result set in the other nine).
|
|
1534
|
+
*
|
|
1535
|
+
* COSTS NOTHING ON THE HAPPY PATH. A well-formed id returns immediately, with no listing read,
|
|
1536
|
+
* so a correct call is byte-identical to before. Only a non-id value pays a listing, and only
|
|
1537
|
+
* because the alternative is answering it with an empty result the caller will read as "no
|
|
1538
|
+
* matches".
|
|
1539
|
+
*
|
|
1540
|
+
* It lives in the adapter rather than in the ten tool files for the reason #371 and #376 both
|
|
1541
|
+
* landed on: a check the tools share belongs where the reads keep `retry` and the observability
|
|
1542
|
+
* call site, and where the next caller of the same listing cannot skip it by accident.
|
|
1543
|
+
*
|
|
1544
|
+
* It THROWS rather than returning a result envelope, per #377's taxonomy: does-not-exist throws,
|
|
1545
|
+
* and `{ success: false }` is only for a genuine multi-outcome contract. A filter id that names
|
|
1546
|
+
* nothing is not a multi-outcome contract. That is also why the four tools that already had an
|
|
1547
|
+
* accommodation stop returning `{ transactions: [], count: 0, error }`: an empty result set with
|
|
1548
|
+
* an error tucked inside it is the shape this ticket exists to remove, not a milder version of it.
|
|
1549
|
+
*
|
|
1550
|
+
* `verifyExists` EXISTS SO THIS CHANGE REMOVES NOTHING, and the asymmetry is deliberate rather
|
|
1551
|
+
* than an oversight, so do not "tidy" it into one behaviour without deciding the same question
|
|
1552
|
+
* again. Five tools already read the listing unconditionally and already refuse a well-formed id
|
|
1553
|
+
* that names nothing; they pass `verifyExists: true` and keep exactly that, with a better message.
|
|
1554
|
+
* The rest never paid a listing read, and making them pay one on EVERY call to catch a mistyped
|
|
1555
|
+
* UUID would impose a cost on every correct call to fix a mistake nobody makes. The mistake that
|
|
1556
|
+
* actually happens, and that this ticket is about, is a NAME passed where an id belongs, which is
|
|
1557
|
+
* caught on both paths because a name is never a UUID.
|
|
1558
|
+
*/
|
|
1559
|
+
export async function resolveFilterId(kind, value, opts) {
|
|
1560
|
+
// The free path, and the one every correct call takes.
|
|
1561
|
+
if (!opts?.verifyExists && isEntityId(value))
|
|
1562
|
+
return value;
|
|
1563
|
+
const { entity, listTool } = FILTER_ID_ENTITIES[kind];
|
|
1564
|
+
// `rows` lets a caller that ALREADY holds the listing avoid a second read of it. Without it,
|
|
1565
|
+
// `transactions_search_by_category` (which fetches accounts anyway, for off-budget filtering
|
|
1566
|
+
// and enrichment) would pay two listing calls on every filtered call, which is a cost this
|
|
1567
|
+
// change is supposed to avoid rather than introduce.
|
|
1568
|
+
const rows = opts?.rows
|
|
1569
|
+
?? (kind === 'account' ? await getAccounts()
|
|
1570
|
+
: kind === 'category' ? await getCategories()
|
|
1571
|
+
: await getPayees());
|
|
1572
|
+
if (isEntityId(value)) {
|
|
1573
|
+
// Only reachable under verifyExists. A well-formed id that names nothing is a not-found,
|
|
1574
|
+
// not a name to resolve.
|
|
1575
|
+
if (rows.some((r) => r.id === value))
|
|
1576
|
+
return value;
|
|
1577
|
+
throw new NotFoundRefusal(entity, value, listTool);
|
|
1578
|
+
}
|
|
1579
|
+
const hit = matchByName(rows, value);
|
|
1580
|
+
if (hit && typeof hit.id === 'string') {
|
|
1581
|
+
const resolved = resolvedNameDetail(kind, String(hit.name), hit.id);
|
|
1582
|
+
throw new NotFoundRefusal(entity, value, listTool, undefined, resolved);
|
|
1583
|
+
}
|
|
1584
|
+
throw new NotFoundRefusal(entity, value, listTool);
|
|
1585
|
+
}
|
|
1357
1586
|
export async function getCommonPayees() {
|
|
1358
1587
|
return withActualApi(async () => {
|
|
1359
1588
|
observability.incrementToolCall('actual.payees.commonList').catch(() => { });
|
|
@@ -3026,6 +3255,7 @@ export async function getPreferences() {
|
|
|
3026
3255
|
}
|
|
3027
3256
|
export default {
|
|
3028
3257
|
getAccounts,
|
|
3258
|
+
resolveFilterId,
|
|
3029
3259
|
getAccountsWithBalances,
|
|
3030
3260
|
addTransactions,
|
|
3031
3261
|
importTransactions,
|
package/dist/src/lib/apiLock.js
CHANGED
|
@@ -149,8 +149,10 @@ function grantNext() {
|
|
|
149
149
|
//
|
|
150
150
|
// Review caught this, and it was the worst thing in the change. An unhinted waiter can never
|
|
151
151
|
// equal `loaded`, so without this it was freely skipped, and the three unhinted acquisitions
|
|
152
|
-
// are the ones least able to afford it: the
|
|
153
|
-
//
|
|
152
|
+
// are the ones least able to afford it: the pool's session open, `shutdownAll`, and a write
|
|
153
|
+
// drain whose batch spans budgets. (#417 later let a UNANIMOUS drain hint its own budget, so
|
|
154
|
+
// that case is no longer a barrier. A mixed batch still is, and must stay so: one entry's
|
|
155
|
+
// budget says nothing about the rest.) Reproduced on a SINGLE-budget setup, where affinity can never save a
|
|
154
156
|
// re-selection because only one budget is ever loaded: a drain enqueued first ran ninth,
|
|
155
157
|
// behind eight reads. So the majority deployment got the reordering with none of the benefit,
|
|
156
158
|
// a write could be overtaken by reads issued after it (impossible under the old FIFO, and
|
package/dist/src/lib/apiState.js
CHANGED
|
@@ -127,6 +127,20 @@ export async function awaitAbandonedBudgetLoad(bound) {
|
|
|
127
127
|
await bound(() => outstanding, 'abandoned budget load');
|
|
128
128
|
return true;
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Is a budget load currently outstanding?
|
|
132
|
+
*
|
|
133
|
+
* Production predicate (#414). The write drain uses it to FAIL FAST rather than pay the abandoned
|
|
134
|
+
* load bound once per remaining operation: a stuck load makes every one of them fail identically,
|
|
135
|
+
* so N operations cost N times ACTUAL_OP_TIMEOUT_MS holding the process-global mutex.
|
|
136
|
+
*
|
|
137
|
+
* Deliberately re-checked per operation rather than latched: a load can land just after a bound
|
|
138
|
+
* expires, and the whole point of keeping the registration is that a late landing is still waited
|
|
139
|
+
* for. Fail fast only while the condition still holds.
|
|
140
|
+
*/
|
|
141
|
+
export function hasPendingBudgetLoad() {
|
|
142
|
+
return _pendingBudgetLoads.size > 0;
|
|
143
|
+
}
|
|
130
144
|
/** Test hook: is a load currently outstanding? */
|
|
131
145
|
export function _hasPendingBudgetLoadForTests() {
|
|
132
146
|
return _pendingBudgetLoads.size > 0;
|
package/dist/src/lib/errors.js
CHANGED
|
@@ -60,8 +60,17 @@ export class NotFoundRefusal extends PreflightRefusal {
|
|
|
60
60
|
* @param listTool MCP tool name the caller should use to get valid ids
|
|
61
61
|
* @param detail Optional extra sentence, e.g. what was NOT written as a result
|
|
62
62
|
*/
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
/**
|
|
64
|
+
* `overrideMessage` is for the one case where we can say something strictly MORE useful than
|
|
65
|
+
* "not found, go and list them": we already know the answer. #388 resolves a NAME passed where
|
|
66
|
+
* an id belongs, so telling the caller to list the accounts buries the id we just found behind
|
|
67
|
+
* an instruction they no longer need, and a model reading top to bottom will follow the
|
|
68
|
+
* instruction rather than the answer. The TYPE is unchanged, because the request still names
|
|
69
|
+
* something that does not exist under that identifier, and #377 is explicit that callers branch
|
|
70
|
+
* on the type rather than on message prose.
|
|
71
|
+
*/
|
|
72
|
+
constructor(entity, id, listTool, detail, overrideMessage) {
|
|
73
|
+
super(overrideMessage ?? (notFoundMsg(entity, id, listTool) + (detail ? ` ${detail}` : '')), 'not-found');
|
|
65
74
|
this.entity = entity;
|
|
66
75
|
this.id = id;
|
|
67
76
|
this.listTool = listTool;
|
|
@@ -9,6 +9,14 @@ const tool = {
|
|
|
9
9
|
inputSchema: InputSchema,
|
|
10
10
|
call: async (args, _meta) => {
|
|
11
11
|
const input = InputSchema.parse(args || {});
|
|
12
|
+
// #388: the one Category B field where `verifyExists: true` is worth a listing read on the
|
|
13
|
+
// HAPPY path too. Every other filter id costs at most a wrong-looking local result; this one
|
|
14
|
+
// reaches a THIRD PARTY, so an id that names nothing spends a provider call (and, with
|
|
15
|
+
// GoCardless, part of a rate-limited quota) to accomplish nothing. Omitting accountId still
|
|
16
|
+
// means "sync everything" and is not validated, because there is nothing to validate.
|
|
17
|
+
if (input.accountId) {
|
|
18
|
+
await adapter.resolveFilterId('account', input.accountId, { verifyExists: true });
|
|
19
|
+
}
|
|
12
20
|
try {
|
|
13
21
|
await adapter.runBankSync(input.accountId ?? undefined);
|
|
14
22
|
return { result: 'Bank sync initiated successfully' };
|
|
@@ -19,6 +19,15 @@ const tool = {
|
|
|
19
19
|
call: async (args, _meta) => {
|
|
20
20
|
const input = InputSchema.parse(args || {});
|
|
21
21
|
// Convert null to undefined for adapter (LibreChat sends null, adapter expects undefined)
|
|
22
|
+
// #388: these three filters silently returned an EMPTY result set when given a NAME, which
|
|
23
|
+
// is the worst of the three answers the surface used to give: a plausible-looking wrong
|
|
24
|
+
// answer. No listing is read for a well-formed id, so a correct call costs what it did.
|
|
25
|
+
if (input.accountId)
|
|
26
|
+
await adapter.resolveFilterId('account', input.accountId);
|
|
27
|
+
if (input.categoryId)
|
|
28
|
+
await adapter.resolveFilterId('category', input.categoryId);
|
|
29
|
+
if (input.payeeId)
|
|
30
|
+
await adapter.resolveFilterId('payee', input.payeeId);
|
|
22
31
|
const accountId = input.accountId ?? undefined;
|
|
23
32
|
const startDate = input.startDate ?? undefined;
|
|
24
33
|
const endDate = input.endDate ?? undefined;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
-
import { notFoundMsg } from '../lib/errors.js';
|
|
4
3
|
const InputSchema = z.object({ accountId: z.string().optional(), startDate: z.string().optional(), endDate: z.string().optional() });
|
|
5
4
|
const tool = {
|
|
6
5
|
name: 'actual_transactions_get',
|
|
@@ -8,13 +7,13 @@ const tool = {
|
|
|
8
7
|
inputSchema: InputSchema,
|
|
9
8
|
call: async (args, _meta) => {
|
|
10
9
|
const input = InputSchema.parse(args || {});
|
|
11
|
-
// Pre-flight: verify account exists when accountId is provided (BUG-7)
|
|
10
|
+
// Pre-flight: verify the account exists when accountId is provided (BUG-7, then #388).
|
|
11
|
+
// This tool already refused a well-formed id that names nothing, and `verifyExists: true`
|
|
12
|
+
// keeps exactly that. What it gains is the third case: a NAME passed where an id belongs
|
|
13
|
+
// now comes back with the id it resolves to, instead of a refusal that only says not found.
|
|
14
|
+
// It also stops returning `{ error }` and THROWS, per #377: does-not-exist throws.
|
|
12
15
|
if (input.accountId) {
|
|
13
|
-
|
|
14
|
-
const accountExists = accounts.some((a) => a.id === input.accountId);
|
|
15
|
-
if (!accountExists) {
|
|
16
|
-
return { error: notFoundMsg('Account', input.accountId, 'actual_accounts_list') };
|
|
17
|
-
}
|
|
16
|
+
await adapter.resolveFilterId('account', input.accountId, { verifyExists: true });
|
|
18
17
|
}
|
|
19
18
|
const result = await adapter.getTransactions(input.accountId, input.startDate, input.endDate);
|
|
20
19
|
return { result };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
import { isPreflightRefusal } from '../lib/errors.js';
|
|
3
4
|
const InputSchema = z.object({
|
|
4
5
|
minAmount: z.number().optional().describe('Minimum amount in cents (use negative for expenses, e.g., -10000 for $-100.00). For expenses, use negative values (e.g., -5000 for -$50.00)'),
|
|
5
6
|
maxAmount: z.number().optional().describe('Maximum amount in cents (e.g., 10000 for $100.00). For expenses, use negative values (e.g., -5000 for -$50.00)'),
|
|
@@ -23,36 +24,17 @@ const tool = {
|
|
|
23
24
|
throw new Error('Unbounded query: provide at least one of accountId, startDate, or endDate ' +
|
|
24
25
|
'to limit the scan scope. Full-database scans without filters can exhaust memory.');
|
|
25
26
|
}
|
|
26
|
-
//
|
|
27
|
+
// #388: ONE answer to a name passed where an id belongs, shared by every Category B field.
|
|
28
|
+
// This block used to return an empty result set with the error tucked inside it, which reads
|
|
29
|
+
// to a model as "no transactions match". `verifyExists: true` keeps the existence check this
|
|
30
|
+
// tool already paid for.
|
|
31
|
+
// Fetched HERE rather than at the enrichment step below so the guard can reuse it.
|
|
32
|
+
// Both need the same listing, and reading it twice on every filtered call is a cost this
|
|
33
|
+
// change is meant to avoid rather than introduce.
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
const accounts = await adapter.getAccounts();
|
|
27
36
|
if (input.accountId) {
|
|
28
|
-
|
|
29
|
-
const accountExists = accounts.some((acc) => acc.id === input.accountId);
|
|
30
|
-
if (!accountExists) {
|
|
31
|
-
// Check if user provided account name instead of UUID
|
|
32
|
-
const accountByName = accounts.find((acc) => acc.name && acc.name.toLowerCase() === input.accountId.toLowerCase());
|
|
33
|
-
if (accountByName) {
|
|
34
|
-
return {
|
|
35
|
-
transactions: [],
|
|
36
|
-
count: 0,
|
|
37
|
-
totalAmount: 0,
|
|
38
|
-
amountRange: {
|
|
39
|
-
min: input.minAmount,
|
|
40
|
-
max: input.maxAmount,
|
|
41
|
-
},
|
|
42
|
-
error: `Account '${input.accountId}' appears to be a name, not an ID. Use account UUID '${accountByName.id}' instead.`,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
return {
|
|
46
|
-
transactions: [],
|
|
47
|
-
count: 0,
|
|
48
|
-
totalAmount: 0,
|
|
49
|
-
amountRange: {
|
|
50
|
-
min: input.minAmount,
|
|
51
|
-
max: input.maxAmount,
|
|
52
|
-
},
|
|
53
|
-
error: `Account '${input.accountId}' not found. Did you mean to use account UUID instead of name? Use actual_accounts_list to get valid account UUIDs.`,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
37
|
+
await adapter.resolveFilterId('account', input.accountId, { verifyExists: true, rows: accounts });
|
|
56
38
|
}
|
|
57
39
|
// Get base transactions (filtered by account and date range if provided)
|
|
58
40
|
const allTransactions = await adapter.getTransactions(input.accountId, input.startDate, input.endDate);
|
|
@@ -111,8 +93,7 @@ const tool = {
|
|
|
111
93
|
return amountB - amountA;
|
|
112
94
|
});
|
|
113
95
|
const limited = filtered.slice(0, input.limit || 100);
|
|
114
|
-
// Enrich transactions with account names
|
|
115
|
-
const accounts = await adapter.getAccounts();
|
|
96
|
+
// Enrich transactions with account names (listing already in hand, see above)
|
|
116
97
|
const accountMap = new Map(accounts.map((acc) => [acc.id, acc.name]));
|
|
117
98
|
const enrichedTransactions = limited.map((t) => ({
|
|
118
99
|
...t,
|
|
@@ -131,6 +112,12 @@ const tool = {
|
|
|
131
112
|
}
|
|
132
113
|
catch (error) {
|
|
133
114
|
const message = error?.message || String(error);
|
|
115
|
+
// #377/#388: a PREFLIGHT REFUSAL is not a crash and must not be flattened into one.
|
|
116
|
+
// This guard exists so an unexpected failure does not take the server down. A refusal is
|
|
117
|
+
// the opposite: a deliberate answer that the request cannot be served, and burying it here
|
|
118
|
+
// would restore exactly the empty-result-with-an-error shape #388 removed.
|
|
119
|
+
if (isPreflightRefusal(error))
|
|
120
|
+
throw error;
|
|
134
121
|
// Don't crash the server — return structured error
|
|
135
122
|
return {
|
|
136
123
|
transactions: [],
|
|
@@ -18,29 +18,12 @@ const tool = {
|
|
|
18
18
|
// Fetch accounts once — used for validation, off-budget filtering (issue #81), and enrichment
|
|
19
19
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
20
|
const allAccounts = await adapter.getAccounts();
|
|
21
|
-
//
|
|
21
|
+
// #388: ONE answer to a name passed where an id belongs, shared by every Category B field.
|
|
22
|
+
// This block used to return an empty result set with the error tucked inside it, which reads
|
|
23
|
+
// to a model as "no transactions match". `verifyExists: true` keeps the existence check this
|
|
24
|
+
// tool already paid for.
|
|
22
25
|
if (input.accountId) {
|
|
23
|
-
|
|
24
|
-
if (!accountExists) {
|
|
25
|
-
// Check if user provided account name instead of UUID
|
|
26
|
-
const accountByName = allAccounts.find((acc) => acc.name && acc.name.toLowerCase() === input.accountId.toLowerCase());
|
|
27
|
-
if (accountByName) {
|
|
28
|
-
return {
|
|
29
|
-
transactions: [],
|
|
30
|
-
count: 0,
|
|
31
|
-
totalAmount: 0,
|
|
32
|
-
categoryName: input.categoryName,
|
|
33
|
-
error: `Account '${input.accountId}' appears to be a name, not an ID. Use account UUID '${accountByName.id}' instead.`,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
return {
|
|
37
|
-
transactions: [],
|
|
38
|
-
count: 0,
|
|
39
|
-
totalAmount: 0,
|
|
40
|
-
categoryName: input.categoryName,
|
|
41
|
-
error: `Account '${input.accountId}' not found. Did you mean to use account UUID instead of name? Use actual_accounts_list to get valid account UUIDs.`,
|
|
42
|
-
};
|
|
43
|
-
}
|
|
26
|
+
await adapter.resolveFilterId('account', input.accountId, { verifyExists: true, rows: allAccounts });
|
|
44
27
|
}
|
|
45
28
|
// Step 1: Find category ID by name
|
|
46
29
|
let categoryId;
|
|
@@ -15,31 +15,17 @@ const tool = {
|
|
|
15
15
|
inputSchema: InputSchema,
|
|
16
16
|
call: async (args, _meta) => {
|
|
17
17
|
const input = InputSchema.parse(args || {});
|
|
18
|
-
//
|
|
18
|
+
// Fetched HERE rather than further down so the #388 guard below can reuse it. The guard
|
|
19
|
+
// needs the listing and so does the off-budget filtering, and without sharing them this
|
|
20
|
+
// handler read the same listing twice on every filtered call.
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
|
+
const accounts = await adapter.getAccounts();
|
|
23
|
+
// #388: ONE answer to a name passed where an id belongs, shared by every Category B field.
|
|
24
|
+
// This block used to return { transactions: [], count: 0, ..., error }, an empty result set
|
|
25
|
+
// with the error tucked inside it, which reads to a model as "no transactions match".
|
|
26
|
+
// `verifyExists: true` keeps the existence check this tool already paid for.
|
|
19
27
|
if (input.accountId) {
|
|
20
|
-
|
|
21
|
-
const accountExists = accounts.some((acc) => acc.id === input.accountId);
|
|
22
|
-
if (!accountExists) {
|
|
23
|
-
// Check if user provided account name instead of UUID
|
|
24
|
-
const accountByName = accounts.find((acc) => acc.name && acc.name.toLowerCase() === input.accountId.toLowerCase());
|
|
25
|
-
const month = input.month || `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
|
26
|
-
if (accountByName) {
|
|
27
|
-
return {
|
|
28
|
-
transactions: [],
|
|
29
|
-
count: 0,
|
|
30
|
-
totalAmount: 0,
|
|
31
|
-
month,
|
|
32
|
-
error: `Account '${input.accountId}' appears to be a name, not an ID. Use account UUID '${accountByName.id}' instead.`,
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
return {
|
|
36
|
-
transactions: [],
|
|
37
|
-
count: 0,
|
|
38
|
-
totalAmount: 0,
|
|
39
|
-
month,
|
|
40
|
-
error: `Account '${input.accountId}' not found. Did you mean to use account UUID instead of name? Use actual_accounts_list to get valid account UUIDs.`,
|
|
41
|
-
};
|
|
42
|
-
}
|
|
28
|
+
await adapter.resolveFilterId('account', input.accountId, { verifyExists: true, rows: accounts });
|
|
43
29
|
}
|
|
44
30
|
// Default to current month if not provided
|
|
45
31
|
const today = new Date();
|
|
@@ -60,9 +46,6 @@ const tool = {
|
|
|
60
46
|
month,
|
|
61
47
|
};
|
|
62
48
|
}
|
|
63
|
-
// Fetch accounts once — used for off-budget filtering (issue #81) and enrichment
|
|
64
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
65
|
-
const accounts = await adapter.getAccounts();
|
|
66
49
|
// Exclude off-budget accounts (issue #81) — their transactions cannot have
|
|
67
50
|
// categories set; any update is silently discarded by Actual Budget.
|
|
68
51
|
const offBudgetIds = new Set(
|
|
@@ -16,30 +16,12 @@ const tool = {
|
|
|
16
16
|
inputSchema: InputSchema,
|
|
17
17
|
call: async (args, _meta) => {
|
|
18
18
|
const input = InputSchema.parse(args || {});
|
|
19
|
-
//
|
|
19
|
+
// #388: ONE answer to a name passed where an id belongs, shared by every Category B field.
|
|
20
|
+
// This block used to return an empty result set with the error tucked inside it, which reads
|
|
21
|
+
// to a model as "no transactions match". `verifyExists: true` keeps the existence check this
|
|
22
|
+
// tool already paid for.
|
|
20
23
|
if (input.accountId) {
|
|
21
|
-
|
|
22
|
-
const accountExists = accounts.some((acc) => acc.id === input.accountId);
|
|
23
|
-
if (!accountExists) {
|
|
24
|
-
// Check if user provided account name instead of UUID
|
|
25
|
-
const accountByName = accounts.find((acc) => acc.name && acc.name.toLowerCase() === input.accountId.toLowerCase());
|
|
26
|
-
if (accountByName) {
|
|
27
|
-
return {
|
|
28
|
-
transactions: [],
|
|
29
|
-
count: 0,
|
|
30
|
-
totalAmount: 0,
|
|
31
|
-
payeeName: input.payeeName,
|
|
32
|
-
error: `Account '${input.accountId}' appears to be a name, not an ID. Use account UUID '${accountByName.id}' instead.`,
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
return {
|
|
36
|
-
transactions: [],
|
|
37
|
-
count: 0,
|
|
38
|
-
totalAmount: 0,
|
|
39
|
-
payeeName: input.payeeName,
|
|
40
|
-
error: `Account '${input.accountId}' not found. Did you mean to use account UUID instead of name? Use actual_accounts_list to get valid account UUIDs.`,
|
|
41
|
-
};
|
|
42
|
-
}
|
|
24
|
+
await adapter.resolveFilterId('account', input.accountId, { verifyExists: true });
|
|
43
25
|
}
|
|
44
26
|
// Step 1: Find payee ID by name
|
|
45
27
|
let payeeId;
|
|
@@ -12,6 +12,11 @@ const tool = {
|
|
|
12
12
|
inputSchema: InputSchema,
|
|
13
13
|
call: async (args, _meta) => {
|
|
14
14
|
const input = InputSchema.parse(args || {});
|
|
15
|
+
// #388: this filter silently returned an EMPTY result set when given an account NAME, a
|
|
16
|
+
// plausible-looking wrong answer. A well-formed id reads no listing, so a correct call is
|
|
17
|
+
// unchanged.
|
|
18
|
+
if (input.accountId)
|
|
19
|
+
await adapter.resolveFilterId('account', input.accountId);
|
|
15
20
|
// Default to current month if dates not provided
|
|
16
21
|
const today = new Date();
|
|
17
22
|
const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
@@ -12,6 +12,11 @@ const tool = {
|
|
|
12
12
|
inputSchema: InputSchema,
|
|
13
13
|
call: async (args, _meta) => {
|
|
14
14
|
const input = InputSchema.parse(args || {});
|
|
15
|
+
// #388: this filter silently returned an EMPTY result set when given an account NAME, a
|
|
16
|
+
// plausible-looking wrong answer. A well-formed id reads no listing, so a correct call is
|
|
17
|
+
// unchanged.
|
|
18
|
+
if (input.accountId)
|
|
19
|
+
await adapter.resolveFilterId('account', input.accountId);
|
|
15
20
|
// Build ActualQL query with groupBy and aggregation
|
|
16
21
|
const api = await import('@actual-app/api');
|
|
17
22
|
const q = api.q;
|
|
@@ -15,7 +15,12 @@ import { CommonSchemas } from '../lib/schemas/common.js';
|
|
|
15
15
|
const InputSchema = z.object({
|
|
16
16
|
startDate: CommonSchemas.date.optional().describe('Start date in YYYY-MM-DD format (default: all-time)'),
|
|
17
17
|
endDate: CommonSchemas.date.optional().describe('End date in YYYY-MM-DD format (default: today)'),
|
|
18
|
-
|
|
18
|
+
// #388: an OPTIONAL FILTER, so it belongs with the other twelve rather than with the required
|
|
19
|
+
// lookup ids #380 tightened. Under `CommonSchemas.accountId` a caller passing an account NAME
|
|
20
|
+
// got "Invalid uuid" and no way to learn the id, which is the answer this ticket argues is worse
|
|
21
|
+
// than resolving the name. The schema is bare so the handler runs and can resolve it; the
|
|
22
|
+
// exception entry in tool_id_schema_drift records that this is deliberate.
|
|
23
|
+
accountId: z.string().optional().describe('Filter to a specific account ID (optional)'),
|
|
19
24
|
includeTransactions: z.boolean().optional().default(false).describe('When true, include paginated transaction rows in the response (default: false)'),
|
|
20
25
|
limit: z.number().int().min(1).max(1000).optional().default(50).describe('Max transactions per page when includeTransactions is true (default: 50, max: 1000)'),
|
|
21
26
|
offset: z.number().int().min(0).optional().default(0).describe('Pagination start index when includeTransactions is true (default: 0)'),
|
|
@@ -46,6 +51,8 @@ const tool = {
|
|
|
46
51
|
const today = new Date();
|
|
47
52
|
const startDate = input.startDate || '2000-01-01';
|
|
48
53
|
const endDate = input.endDate || today.toISOString().split('T')[0];
|
|
54
|
+
if (input.accountId)
|
|
55
|
+
await adapter.resolveFilterId('account', input.accountId);
|
|
49
56
|
const accountId = input.accountId ?? undefined;
|
|
50
57
|
// Full table scan when no accountId — reliably returns newly written transactions
|
|
51
58
|
// across separate WAL sessions in CI Docker. Filtering by accountId uses an index
|
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.10",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|