@slothmoney/agent-cli 0.16.0 → 0.17.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0 - 2026-08-25
4
+
5
+ - Run applied transaction-assignment batches through resumable server
6
+ operations while keeping the existing `assign --input ... --apply` command
7
+ and terminal `succeeded`/`failed` JSON output unchanged.
8
+ - Retry transient submission and status requests with a deterministic
9
+ request key, so re-running an interrupted command resumes the same operation.
10
+ - Strictly validate operation progress, expiry, counts, and ordered terminal
11
+ item receipts before printing an assignment result.
12
+
3
13
  ## 0.16.0 - 2026-08-21
4
14
 
5
15
  - Add receipt image extraction plus read, preview-by-default attach, and remove
package/README.md CHANGED
@@ -15,7 +15,7 @@ sloth-agent --version
15
15
  For a one-off pinned run:
16
16
 
17
17
  ```bash
18
- npm exec --yes --package=@slothmoney/agent-cli@0.16.0 -- sloth-agent --help
18
+ npm exec --yes --package=@slothmoney/agent-cli@0.17.0 -- sloth-agent --help
19
19
  ```
20
20
 
21
21
  ## Authenticate
@@ -302,6 +302,10 @@ assignment, set `"assignmentScope": "joint"` in the assignment payload and use
302
302
 
303
303
  These are placeholders. Do not submit the example values.
304
304
 
305
+ Each `transactionRef` may appear only once in an assignment file. Split one
306
+ transaction across categories with `categorySplits` instead of adding the same
307
+ transaction twice.
308
+
305
309
  4. Preview the assignment without writing:
306
310
 
307
311
  ```bash
@@ -321,7 +325,17 @@ sloth-agent assign --input assignments.json --apply
321
325
 
322
326
  This step requires a token created with **Allow changes**.
323
327
 
324
- Inspect every item in the returned `succeeded` and `failed` arrays.
328
+ The CLI submits a durable server operation and polls its authenticated status
329
+ until every item has finished. It then prints the same `succeeded` and `failed`
330
+ arrays as before, so existing agent workflows do not need to change. Inspect
331
+ every item in both arrays.
332
+
333
+ If the command is interrupted or a request times out, re-run the same command
334
+ with the same assignment input. The CLI derives the same request key
335
+ from the validated assignments, so the server resumes the existing operation
336
+ instead of applying the batch again. The server retains operation status and
337
+ item receipts for seven days. Changing the assignments creates a different
338
+ operation.
325
339
 
326
340
  6. Check the result in the same assignment scope that you changed. Successful
327
341
  assignments update the category and optional budget line item on the
@@ -765,8 +779,10 @@ Command results are JSON on stdout. Diagnostics are written to stderr.
765
779
  | `2` | Invalid command, option, URL, date, auth input, goal input, or assignment input |
766
780
  | `3` | No credential or native secure storage is unavailable |
767
781
 
768
- Assignment writes are best-effort. A response containing any failed assignment
769
- returns exit code `1` while preserving the complete API response on stdout.
782
+ Assignment writes run as durable, best-effort operations. The CLI waits for the
783
+ terminal result and returns exit code `1` when any item failed, while preserving
784
+ the complete `succeeded` and `failed` arrays on stdout. Re-running an interrupted
785
+ command with the same input resumes the same server operation.
770
786
 
771
787
  ## Development
772
788
 
package/dist/cli.js CHANGED
@@ -1,11 +1,12 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import nodePath from 'node:path';
3
4
  import { parseArgs, resolveBaseUrl, } from './args.js';
4
5
  import { ICON_KEYS } from './category-metadata.js';
5
- import { parseApiResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, validateNotificationRulePayload, validateReceiptConfirmation, } from './contracts.js';
6
+ import { parseApiResponse, parseAssignmentOperationResponse, toLegacyAssignmentResponse, validateAssignmentPayload, validateBudgetMovementResponse, validateBudgetUpdatePayload, validateNotificationRulePayload, validateReceiptConfirmation, } from './contracts.js';
6
7
  import { createSystemCredentialStore, secureStorageUnavailableError, } from './credential-store.js';
7
8
  import { ApiError, CliError, ConfigError, UsageError, } from './errors.js';
8
- export const CLI_VERSION = '0.16.0';
9
+ export const CLI_VERSION = '0.17.0';
9
10
  const REQUEST_TIMEOUT_MS = 60_000;
10
11
  const MAX_CONTRACT_PDF_BYTES = 6_000_000;
11
12
  const API_ORIGIN_HELP_LINES = [
@@ -16,6 +17,8 @@ const API_ORIGIN_HELP_LINES = [
16
17
  ' Use an origin-only URL with no credentials, path, query, or fragment.',
17
18
  ' HTTPS is required except for localhost development.',
18
19
  ];
20
+ const ASSIGNMENT_REQUEST_ATTEMPTS = 3;
21
+ const ASSIGNMENT_RETRY_DELAY_MS = 500;
19
22
  export function usageText() {
20
23
  return [
21
24
  'Sloth Agent CLI',
@@ -654,13 +657,19 @@ export function assignHelpText() {
654
657
  ' the payload it would send. It does not contact Sloth Money, verify the',
655
658
  ' transactionRef or category values, or write anything.',
656
659
  ' A successful preview does not guarantee that applying it will succeed.',
657
- ' With --apply, assignments are best-effort; any failed item makes the command',
658
- ' exit with code 1 while the complete result remains available on stdout.',
660
+ ' With --apply, the CLI submits one durable server operation, then polls authenticated status',
661
+ ' until every item finishes. Transient submission and status failures are retried.',
662
+ ' Re-run the same command with the same assignment input after an interruption;',
663
+ ' the CLI resumes the same operation instead of duplicating its work.',
664
+ ' Operation status and item receipts remain available on the server for seven days.',
665
+ ' Assignments are best-effort; any failed item makes the command exit with code 1',
666
+ ' while the complete terminal result remains available on stdout.',
659
667
  ' Applying requires a write-enabled token created with Allow changes.',
660
668
  '',
661
669
  'Input:',
662
670
  ' The top-level object must contain an assignments array.',
663
671
  ' Each assignment requires transactionRef and at least one category operation or sharing object.',
672
+ ' Each transactionRef may appear only once in the assignments array.',
664
673
  ' sharing.isShared is required. shareRatio is optional from 0 to 1 and is your share.',
665
674
  ' userExclusiveAmountPence and partnerExclusiveAmountPence are optional nonnegative integers.',
666
675
  ' Omitted split values use saved defaults for a first share and preserve an existing split.',
@@ -702,7 +711,7 @@ export function assignHelpText() {
702
711
  '',
703
712
  'Output:',
704
713
  ' Preview mode returns dryRun, endpoint, and the validated payload.',
705
- ' Apply mode returns succeeded and failed assignment arrays.',
714
+ ' Apply mode waits for the durable operation and returns succeeded and failed arrays.',
706
715
  ' Successful assignments update the original transaction. See the result in',
707
716
  ' Sloth Money → Transactions or read the transaction again through the CLI.',
708
717
  ' Assignments do not create a separate list.',
@@ -1407,6 +1416,99 @@ function requestHeaders(token) {
1407
1416
  'User-Agent': `sloth-agent/${CLI_VERSION}`,
1408
1417
  };
1409
1418
  }
1419
+ function assignmentIdempotencyKey(payload) {
1420
+ return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
1421
+ }
1422
+ function isRetryableAssignmentRequestError(error) {
1423
+ if (error instanceof ApiError) {
1424
+ return error.status !== undefined
1425
+ && [408, 425, 429, 499, 500, 502, 503, 504].includes(error.status);
1426
+ }
1427
+ return error instanceof TypeError
1428
+ || (error instanceof Error && error.name === 'AbortError');
1429
+ }
1430
+ async function withAssignmentRequestRecovery(request, sleep) {
1431
+ let lastError;
1432
+ for (let attempt = 1; attempt <= ASSIGNMENT_REQUEST_ATTEMPTS; attempt += 1) {
1433
+ try {
1434
+ return await request();
1435
+ }
1436
+ catch (error) {
1437
+ lastError = error;
1438
+ if (!isRetryableAssignmentRequestError(error) || attempt === ASSIGNMENT_REQUEST_ATTEMPTS) {
1439
+ throw error;
1440
+ }
1441
+ await sleep(ASSIGNMENT_RETRY_DELAY_MS * attempt);
1442
+ }
1443
+ }
1444
+ throw lastError;
1445
+ }
1446
+ async function parseAssignmentOperationHttpResponse(response, token, expectedStatus) {
1447
+ const data = await parseHttpResponse(response, token);
1448
+ if (response.status !== expectedStatus) {
1449
+ throw new ApiError(`Agent API returned status ${response.status}; expected ${expectedStatus}`, response.status);
1450
+ }
1451
+ return parseAssignmentOperationResponse(data);
1452
+ }
1453
+ function assertAssignmentOperationMatchesPayload(operation, payload, expectedOperationId) {
1454
+ if (operation.itemCount !== payload.assignments.length
1455
+ || (expectedOperationId !== undefined && operation.operationId !== expectedOperationId)
1456
+ || (operation.status === 'completed'
1457
+ && operation.results?.some((result, index) => (result.transactionRef !== payload.assignments[index]?.transactionRef)))) {
1458
+ throw new ApiError('Assignment operation response did not match the submitted assignment order');
1459
+ }
1460
+ }
1461
+ async function applyAssignments(fetchImplementation, sleep, baseUrl, token, payload) {
1462
+ const endpoint = `${baseUrl}/api/agent/v1/transaction-assignments`;
1463
+ const idempotencyKey = assignmentIdempotencyKey(payload);
1464
+ let operation;
1465
+ try {
1466
+ operation = await withAssignmentRequestRecovery(async () => {
1467
+ const response = await fetchImplementation(endpoint, {
1468
+ method: 'POST',
1469
+ headers: {
1470
+ ...requestHeaders(token),
1471
+ 'Content-Type': 'application/json',
1472
+ 'Idempotency-Key': idempotencyKey,
1473
+ },
1474
+ body: JSON.stringify(payload),
1475
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1476
+ });
1477
+ return parseAssignmentOperationHttpResponse(response, token, 202);
1478
+ }, sleep);
1479
+ }
1480
+ catch (error) {
1481
+ if (!isRetryableAssignmentRequestError(error))
1482
+ throw error;
1483
+ throw new ApiError('Assignment submission could not be confirmed. '
1484
+ + 'Re-run the same command with the same assignment input to resume it.');
1485
+ }
1486
+ assertAssignmentOperationMatchesPayload(operation, payload);
1487
+ const operationId = operation.operationId;
1488
+ while (operation.status !== 'completed') {
1489
+ await sleep(operation.pollAfterMs);
1490
+ const statusEndpoint = `${endpoint}/${encodeURIComponent(operationId)}`;
1491
+ try {
1492
+ const nextOperation = await withAssignmentRequestRecovery(async () => {
1493
+ const response = await fetchImplementation(statusEndpoint, {
1494
+ method: 'GET',
1495
+ headers: requestHeaders(token),
1496
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1497
+ });
1498
+ return parseAssignmentOperationHttpResponse(response, token, 200);
1499
+ }, sleep);
1500
+ assertAssignmentOperationMatchesPayload(nextOperation, payload, operationId);
1501
+ operation = nextOperation;
1502
+ }
1503
+ catch (error) {
1504
+ if (!isRetryableAssignmentRequestError(error))
1505
+ throw error;
1506
+ throw new ApiError('Assignment status could not be recovered. '
1507
+ + 'Re-run the same command with the same assignment input to resume it.');
1508
+ }
1509
+ }
1510
+ return toLegacyAssignmentResponse(operation);
1511
+ }
1410
1512
  async function validateCredentialRemotely(fetchImplementation, origin, token) {
1411
1513
  const response = await fetchImplementation(`${origin}/api/agent/v1/categories`, {
1412
1514
  method: 'GET',
@@ -1442,6 +1544,9 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1442
1544
  ?? Boolean(process.stdin.isTTY && process.stderr.isTTY);
1443
1545
  const readSecret = options.readSecret ?? defaultReadSecret;
1444
1546
  const readStdin = options.readStdin ?? defaultReadStdin;
1547
+ const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => {
1548
+ setTimeout(resolve, milliseconds);
1549
+ }));
1445
1550
  const writeStdout = options.writeStdout ?? ((value) => process.stdout.write(value));
1446
1551
  const writeStderr = options.writeStderr ?? ((value) => process.stderr.write(value));
1447
1552
  let token;
@@ -1884,17 +1989,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
1884
1989
  }
1885
1990
  if (parsed.command === 'assign') {
1886
1991
  const payload = assignmentPayload;
1887
- const endpoint = `${baseUrl}/api/agent/v1/transaction-assignments`;
1888
- const response = await fetchImplementation(endpoint, {
1889
- method: 'POST',
1890
- headers: {
1891
- ...headers,
1892
- 'Content-Type': 'application/json',
1893
- },
1894
- body: JSON.stringify(payload),
1895
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1896
- });
1897
- const data = parseApiResponse('assign', await parseHttpResponse(response, token));
1992
+ const data = await applyAssignments(fetchImplementation, sleep, baseUrl, token, payload);
1898
1993
  writeJson(writeStdout, data);
1899
1994
  return hasFailures(data) ? 1 : 0;
1900
1995
  }
package/dist/contracts.js CHANGED
@@ -269,7 +269,15 @@ export function validateAssignmentPayload(value) {
269
269
  if (payload.assignments.length < 1 || payload.assignments.length > 100) {
270
270
  throw new UsageError('assignments must contain between 1 and 100 items');
271
271
  }
272
- return { assignments: payload.assignments.map(validateAssignment) };
272
+ const assignments = payload.assignments.map(validateAssignment);
273
+ const transactionRefs = new Set();
274
+ for (const assignment of assignments) {
275
+ if (transactionRefs.has(assignment.transactionRef)) {
276
+ throw new UsageError('Each assignments[].transactionRef must be unique');
277
+ }
278
+ transactionRefs.add(assignment.transactionRef);
279
+ }
280
+ return { assignments };
273
281
  }
274
282
  export function validateBudgetUpdatePayload(value) {
275
283
  const payload = requireObject(value, 'budget update payload');
@@ -505,6 +513,73 @@ function isAssignmentResponse(value) {
505
513
  && typeof item.error === 'string'
506
514
  && (item.transactionRef === undefined || typeof item.transactionRef === 'string'))));
507
515
  }
516
+ function isAssignmentOperationResult(value) {
517
+ if (!isObject(value) || typeof value.transactionRef !== 'string')
518
+ return false;
519
+ const { status, ...legacyResult } = value;
520
+ if (status === 'succeeded') {
521
+ return isAssignmentResponse({ succeeded: [legacyResult], failed: [] });
522
+ }
523
+ if (status === 'failed') {
524
+ return isAssignmentResponse({ succeeded: [], failed: [legacyResult] });
525
+ }
526
+ return false;
527
+ }
528
+ export function parseAssignmentOperationResponse(value) {
529
+ const validBase = isObject(value)
530
+ && hasOnlyFields(value, [
531
+ 'operationId', 'status', 'itemCount', 'completedCount', 'failedCount',
532
+ 'expiresAt', 'pollAfterMs', 'results',
533
+ ])
534
+ && typeof value.operationId === 'string'
535
+ && /^[a-f0-9]{64}$/.test(value.operationId)
536
+ && (value.status === 'pending'
537
+ || value.status === 'processing'
538
+ || value.status === 'completed')
539
+ && Number.isSafeInteger(value.itemCount)
540
+ && Number(value.itemCount) >= 1
541
+ && Number(value.itemCount) <= 100
542
+ && isNonnegativeSafeInteger(value.completedCount)
543
+ && Number(value.completedCount) <= Number(value.itemCount)
544
+ && isNonnegativeSafeInteger(value.failedCount)
545
+ && Number(value.failedCount) <= Number(value.completedCount)
546
+ && isIsoDateTime(value.expiresAt)
547
+ && isNonnegativeSafeInteger(value.pollAfterMs)
548
+ && Number(value.pollAfterMs) >= 100
549
+ && Number(value.pollAfterMs) <= 10_000;
550
+ if (!validBase) {
551
+ throw new ApiError('Invalid assignment operation response from the Agent API');
552
+ }
553
+ const isComplete = value.status === 'completed';
554
+ const resultsAreValid = isComplete
555
+ ? (Number(value.completedCount) === Number(value.itemCount)
556
+ && Array.isArray(value.results)
557
+ && value.results.length === Number(value.itemCount)
558
+ && value.results.every(isAssignmentOperationResult)
559
+ && value.results.filter((result) => result.status === 'failed').length
560
+ === Number(value.failedCount))
561
+ : value.results === undefined;
562
+ if (!resultsAreValid) {
563
+ throw new ApiError('Invalid assignment operation response from the Agent API');
564
+ }
565
+ return value;
566
+ }
567
+ export function toLegacyAssignmentResponse(value) {
568
+ const operation = parseAssignmentOperationResponse(value);
569
+ if (operation.status !== 'completed' || !operation.results) {
570
+ throw new ApiError('Assignment operation is not complete');
571
+ }
572
+ const succeeded = [];
573
+ const failed = [];
574
+ for (const result of operation.results) {
575
+ const { status, ...legacyResult } = result;
576
+ if (status === 'succeeded')
577
+ succeeded.push(legacyResult);
578
+ else
579
+ failed.push(legacyResult);
580
+ }
581
+ return { succeeded, failed };
582
+ }
508
583
  function isHttpUrl(value) {
509
584
  if (typeof value !== 'string')
510
585
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slothmoney/agent-cli",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Command-line access to the Sloth Money Agent API.",
5
5
  "type": "module",
6
6
  "bin": {