@thehammer/danx-dashboard-mcp 0.1.92 → 0.1.93

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/dist/bridge.js CHANGED
@@ -42,9 +42,15 @@
42
42
  * 5. Streams through `runListener` (`listen.ts`), which reconnects within the
43
43
  * ticket, and re-mints when that ticket's lease runs out or a fresh ticket
44
44
  * is refused once.
45
- * 6. Writes JSON Lines to stdout: ONE `{type:"ready"}` once a ticket has been
46
- * minted and streaming is about to start, `{type:"event", id, text}` per
47
- * event, and ONE final `{type:"stopped", reason, detail, fix}` before exiting.
45
+ * 6. Writes JSON Lines to stdout: ONE `{type:"ready", boards, cardCount, degraded}`
46
+ * once a ticket has been minted and streaming is about to start —
47
+ * DX-2970's `fetchPlanInventory`, a single BEST-EFFORT (never a gate)
48
+ * read of the connected plan's real board/card counts, purely so a
49
+ * consumer can tell "healthy, connected plan has cards" apart from
50
+ * "degraded — the connected plan has no cards, or its inventory
51
+ * couldn't be read" instead of both reading as an absent/empty field —
52
+ * then `{type:"event", id, text}` per event, and ONE final
53
+ * `{type:"stopped", reason, detail, fix}` before exiting.
48
54
  *
49
55
  * TERMINAL OUTCOMES — each ends the process with its stop record, never a retry:
50
56
  * - `no_connection_record` this session has no connection record to read;
@@ -156,6 +162,12 @@ function formatDigest(entries) {
156
162
  }
157
163
  export const BRIDGE_SUBCOMMAND = "bridge";
158
164
  export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
165
+ // DX-2970 — the bare (no `fields=`) cheap-scalars read, used ONLY to report the
166
+ // connected plan's real `boards`/`cardCount` on the `ready` record (see
167
+ // `fetchPlanInventory`). This is NOT a revival of DX-2920's deleted
168
+ // `verifyPlanBoardsReadable` gate — it never refuses a mint, never blocks
169
+ // streaming, and is best-effort (one attempt, no retry/backoff).
170
+ export const PLAN_MINE_PATH = "/api/plans/mine";
159
171
  export const SESSION_ID_HEADER = "x-danx-session-id";
160
172
  /** How long one dashboard request may take before it counts as a transient failure. */
161
173
  export const REQUEST_TIMEOUT_MS = 10_000;
@@ -427,6 +439,44 @@ export async function mintTicket(options, deps) {
427
439
  leaseMs: ticket.leaseMs,
428
440
  };
429
441
  }
442
+ /** What is wrong with a 2xx bare `/api/plans/mine` body, or `null` when it carries a usable inventory. */
443
+ function badInventoryReason(body) {
444
+ if (typeof body !== "object" || body === null)
445
+ return "the body is not a JSON object";
446
+ const b = body;
447
+ if (!Array.isArray(b.boards) || b.boards.some((x) => typeof x !== "string"))
448
+ return "boards is not a string array";
449
+ if (typeof b.cardCount !== "number" || !Number.isSafeInteger(b.cardCount) || b.cardCount < 0) {
450
+ return "cardCount is not a non-negative integer";
451
+ }
452
+ return null;
453
+ }
454
+ /**
455
+ * DX-2970 — a single BEST-EFFORT read of the connected plan's real
456
+ * `boards`/`cardCount`, purely for accurate `ready`-record reporting. NEVER a
457
+ * gate: unlike DX-2920's deleted `verifyPlanBoardsReadable`, a failure here
458
+ * (network, timeout, non-2xx, unusable body) is NOT retried and NEVER blocks
459
+ * or delays streaming — `runBridge` reports `degraded: true` with `boards`/
460
+ * `cardCount` both `null` and proceeds exactly as it would have before this
461
+ * read existed. The bare (no `fields=`) call is deliberately the cheapest
462
+ * read the route offers — the plan's own "cheap scalars," always present on
463
+ * every `GET /api/plans/mine` response regardless of `fields`.
464
+ */
465
+ export async function fetchPlanInventory(options, deps) {
466
+ const outcome = await request(options, deps, "plan inventory read", `${options.dashboardUrl}${PLAN_MINE_PATH}`);
467
+ if (outcome.kind === "transient")
468
+ return { kind: "unavailable", detail: outcome.detail };
469
+ const { status, text, body } = outcome;
470
+ if (status < 200 || status >= 300) {
471
+ return { kind: "unavailable", detail: `plan inventory read HTTP ${status} ${oneLine(text, 300)}` };
472
+ }
473
+ const bad = badInventoryReason(body);
474
+ if (bad !== null) {
475
+ return { kind: "unavailable", detail: `plan inventory read HTTP ${status}: ${bad}` };
476
+ }
477
+ const b = body;
478
+ return { kind: "ok", boards: b.boards, cardCount: b.cardCount };
479
+ }
430
480
  /**
431
481
  * DX-2920 — a stream admission refusal (`listen.ts`'s STRUCTURED `refused`
432
482
  * outcome — `{status, errorCode, body}`, parsed once in `connectOnce`) whose
@@ -579,9 +629,24 @@ export async function runBridge(options, deps) {
579
629
  // board reach is proven by the dashboard's own mint refusal rather than a
580
630
  // client-side check run here (see the module docblock + the deleted
581
631
  // `verifyPlanBoardsReadable`). A re-mint (a lapsed lease) never repeats it.
632
+ // DX-2970 — the inventory read (`fetchPlanInventory`) runs on this SAME
633
+ // first-mint-only path, for the same reason: it is a property of the
634
+ // credential and the plan, and a re-mint changes neither. Best-effort —
635
+ // never gates the `for(;;)` loop, never retried, never delays streaming.
582
636
  if (!readyEmitted) {
583
637
  readyEmitted = true;
584
- deps.write({ type: "ready" });
638
+ const inventory = await fetchPlanInventory(options, deps);
639
+ if (inventory.kind === "ok") {
640
+ deps.write({
641
+ type: "ready",
642
+ boards: inventory.boards,
643
+ cardCount: inventory.cardCount,
644
+ degraded: inventory.boards.length === 0 || inventory.cardCount === 0,
645
+ });
646
+ }
647
+ else {
648
+ deps.write({ type: "ready", boards: null, cardCount: null, degraded: true });
649
+ }
585
650
  }
586
651
  backoff = MINT_INITIAL_BACKOFF_MS;
587
652
  const run = { stopped: null, emitted: false };
package/dist/handlers.js CHANGED
@@ -70,6 +70,15 @@ export const LIST_PAGE_MAX_LIMIT = 1000;
70
70
  * `__tests__/handlers.test.ts` asserts the two agree.
71
71
  */
72
72
  export const PLAN_GET_CARDS_DEFAULT_LIMIT = 200;
73
+ /**
74
+ * DX-3027 — `plan_get`'s `events_limit` default/max — mirrors the server's
75
+ * `src/issues/plans-routes.ts#PLAN_GET_EVENTS_DEFAULT_LIMIT` /
76
+ * `PLAN_GET_EVENTS_MAX_LIMIT`. Deliberately smaller than `LIST_PAGE_MAX_LIMIT`
77
+ * above — an event page is read far more often, and a wide page defeats the
78
+ * point of a cursor. `__tests__/handlers.test.ts` asserts the two agree.
79
+ */
80
+ export const PLAN_GET_EVENTS_DEFAULT_LIMIT = 50;
81
+ export const PLAN_GET_EVENTS_MAX_LIMIT = 200;
73
82
  /**
74
83
  * Fetch one card via `GET /api/issues/:id`, or many via
75
84
  * `GET /api/issues/batch?ids=...` (DX-2727) — a real batch read that keeps
@@ -857,7 +866,54 @@ export const PLAN_FIELD_GROUPS = [
857
866
  "architecture",
858
867
  "sessions",
859
868
  "notes",
869
+ "events",
870
+ ];
871
+ /**
872
+ * DX-3027 — every kind `plan_get`'s `events_kinds` filter accepts. The ONE
873
+ * copy in this package (the `plan_get` zod enum in `index.ts` reads this
874
+ * const), duplicated from the server's `src/issues/db/plan-events.ts#PLAN_EVENT_KINDS`
875
+ * because the published package cannot import server source at runtime.
876
+ * `__tests__/handlers.test.ts` asserts the two are equal, so drift fails a
877
+ * test rather than a live call.
878
+ */
879
+ export const PLAN_EVENT_KINDS = [
880
+ "comment_added",
881
+ "solution_answered",
882
+ "problem_added",
883
+ "problem_unanswered",
884
+ "problem_answer_changed",
885
+ "blocked",
886
+ "unblocked",
887
+ "transitioned",
888
+ "edited",
889
+ "dependency_added",
890
+ "dependency_removed",
891
+ "card_created",
892
+ "plan_created",
893
+ "plan_renamed",
894
+ "card_added_to_plan",
895
+ "card_removed_from_plan",
896
+ "record_added",
897
+ "record_updated",
898
+ "record_removed",
899
+ "note_added",
900
+ "note_updated",
901
+ "note_removed",
902
+ "architecture_section_added",
903
+ "architecture_section_updated",
904
+ "architecture_section_removed",
905
+ "architecture_sections_reordered",
906
+ "session_connected",
907
+ "session_switched_away",
908
+ "idle_nudge_sent",
860
909
  ];
910
+ /**
911
+ * DX-3027 — every origin `plan_get`'s `events_origin` filter accepts.
912
+ * Mirrors the server's `src/issues/db/plan-events.ts#PlanEventOrigin`
913
+ * (itself `ActivityOrigin`) — same drift protection as `PLAN_EVENT_KINDS`
914
+ * above.
915
+ */
916
+ export const PLAN_EVENT_ORIGINS = ["operator", "agent", "machine"];
861
917
  /**
862
918
  * DX-2834 — the plan-status taxonomy, mirroring `PLAN_FIELD_GROUPS` just
863
919
  * above: the ONE copy in this package (the `plan_list` zod enum in
@@ -892,6 +948,17 @@ export async function planGet(client, args = {}) {
892
948
  query.cards_offset = args.cards_offset;
893
949
  if (args.cards_limit !== undefined)
894
950
  query.cards_limit = args.cards_limit;
951
+ if (args.events_limit !== undefined)
952
+ query.events_limit = args.events_limit;
953
+ if (args.events_before !== undefined)
954
+ query.events_before = args.events_before;
955
+ if (args.events_kinds !== undefined && args.events_kinds.length > 0) {
956
+ query.events_kinds = args.events_kinds.join(",");
957
+ }
958
+ if (args.events_origin !== undefined)
959
+ query.events_origin = args.events_origin;
960
+ if (args.events_writer !== undefined)
961
+ query.events_writer = args.events_writer;
895
962
  return client.request({
896
963
  method: "GET",
897
964
  path: args.plan_id === undefined ? "/mine" : `/${args.plan_id}/full`,
package/dist/index.js CHANGED
@@ -101,7 +101,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
101
101
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
102
102
  import { z } from "zod";
103
103
  import { DashboardHttpClient } from "./http-client.js";
104
- import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, planAddArchitectureSection, planAddCard, planAddNote, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteNote, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateNote, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
104
+ import { issueAttach, issueChecklist, issueComment, issueCreate, issueDependency, issueEdit, issueGet, issueList, issueProblem, issueQualityGate, issueQualityGateVerdict, issueRetireBranch, issueRetro, issueSolution, issueTransition, issueTriage, briefGetPage, briefList, briefSetPage, failureCategoryCreate, failureCategoryList, failureCategoryUpdate, planAddArchitectureSection, planAddCard, planAddNote, planAddRecord, planConnect, planCreate, planDeleteArchitectureSection, planDeleteNote, planDeleteRecord, planGet, PLAN_FIELD_GROUPS, PLAN_EVENT_KINDS, PLAN_EVENT_ORIGINS, PLAN_STATUSES, ISSUE_BATCH_GET_MAX, LIST_PAGE_MAX_LIMIT, PLAN_GET_CARDS_DEFAULT_LIMIT, PLAN_GET_EVENTS_DEFAULT_LIMIT, PLAN_GET_EVENTS_MAX_LIMIT, planGetArchitectureSection, planGetRecord, planList, planRemoveCard, planRename, planReorderArchitectureSection, planUpdateArchitectureSection, planUpdateNote, planUpdateRecord, repoKnowledgeGet, repoKnowledgeSet, } from "./handlers.js";
105
105
  import { PRIORITY_TIER_WORDS } from "./priority.js";
106
106
  function readEnvOrDie(name) {
107
107
  const v = process.env[name];
@@ -723,7 +723,7 @@ server.tool("plan_list", "List every plan via GET /api/plans (DX-2683), and lear
723
723
  .optional()
724
724
  .describe("Filter to one computed status: awaiting-session, planning, building, complete. Omit for every plan."),
725
725
  }, async (args) => jsonResult(await planList(client, args)));
726
- server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id` to read ANY plan; OMIT to read the plan this session is connected to — omitting while connected to none fails loud `{error: \"session_not_connected\"}`, connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{plan, boards, cardCount, bucketCounts, status, session, sessionListenerAttached, available_field_groups}` — no cards, records, or architecture body. `plan` carries `{id, ref, name, createdAt}`; `ref` is the plan's short reference (`PLN-<id>`) — cite that, not the bare id. `status` (DX-2834) is computed fresh on every read, never stored — see `plan_list` for the four values and what each means. Pass `fields` to opt into: `cards` (member cards, PAGED: `cards_offset` (default 0) and `cards_limit` (1.." + LIST_PAGE_MAX_LIMIT + ", default " + PLAN_GET_CARDS_DEFAULT_LIMIT + ") pick the page, in stable card-reference order (board prefix, then card number — pages never repeat/skip unless membership changes between reads); response carries `cards_total`/`cards_offset` — page while cards_offset + cards.length < cards_total; either paging arg without `fields: [\"cards\"]` is a 400), `records` (every goal+rule+caveat, keyed by kind) or `records:goal` / `records:rule` / `records:caveat` (one kind, cheaper), `architecture` (`{sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}`), `sessions` (every session connected to the plan). `session`/`sessionListenerAttached` and `available_field_groups` ride every response regardless. `sessionListenerAttached` is `false` for a few seconds right after `plan_connect` while the plugin's event bridge starts; still `false` after that while connected means the plan's card events are not reaching you — tell the operator. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `contentHash` back as `base_hash`.", {
726
+ server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id` to read ANY plan; OMIT to read the plan this session is connected to — omitting while connected to none fails loud `{error: \"session_not_connected\"}`, connect first. A BARE call (no `fields`) returns ONLY the plan's cheap scalars: `{plan, boards, cardCount, bucketCounts, status, session, sessionListenerAttached, available_field_groups}` — no cards, records, or architecture body. `plan` carries `{id, ref, name, createdAt}`; `ref` is the plan's short reference (`PLN-<id>`) — cite that, not the bare id. `status` (DX-2834) is computed fresh on every read, never stored — see `plan_list` for the four values and what each means. Pass `fields` to opt into: `cards` (member cards, PAGED: `cards_offset` (default 0) and `cards_limit` (1.." + LIST_PAGE_MAX_LIMIT + ", default " + PLAN_GET_CARDS_DEFAULT_LIMIT + ") pick the page, in stable card-reference order (board prefix, then card number — pages never repeat/skip unless membership changes between reads); response carries `cards_total`/`cards_offset` — page while cards_offset + cards.length < cards_total; either paging arg without `fields: [\"cards\"]` is a 400), `records` (every goal+rule+caveat, keyed by kind) or `records:goal` / `records:rule` / `records:caveat` (one kind, cheaper), `architecture` (`{sections: [{id, planId, contentHash, title, content, sortOrder, createdAt, updatedAt}]}`), `sessions` (every session connected to the plan), `events` (DX-2987/DX-3027 — the plan's durable event ledger: every human action and every bridge message recorded on it, cursor-paged newest first via `events_limit` (1.." + PLAN_GET_EVENTS_MAX_LIMIT + ", default " + PLAN_GET_EVENTS_DEFAULT_LIMIT + ") and `events_before` (an opaque cursor — pass a previous page's `next_cursor` to read older; omit for the newest page); filter with `events_kinds` (one or more of " + PLAN_EVENT_KINDS.join(", ") + "), `events_origin` (one of " + PLAN_EVENT_ORIGINS.join(", ") + "), `events_writer` (exact writer name); every `events_*` param without `fields: [\"events\"]` is a 400, same as the `cards_*` params above; response carries `events: {items: [{id, at, kind, writer, origin, originSessionId, targetSessionId, cardId, cardTitle, boardId, detail}], next_cursor}` — `next_cursor` is `null` on the last page; an event on a card whose board you cannot read is left out, plan-level events are always visible). `session`/`sessionListenerAttached` and `available_field_groups` ride every response regardless. `sessionListenerAttached` is `false` for a few seconds right after `plan_connect` while the plugin's event bridge starts; still `false` after that while connected means the plan's card events are not reaching you — tell the operator. ALWAYS `plan_get`/`plan_get_architecture_section` immediately before `plan_update_architecture_section`/`plan_delete_architecture_section` and pass the returned `contentHash` back as `base_hash`.", {
727
727
  plan_id: z
728
728
  .number()
729
729
  .int()
@@ -733,7 +733,7 @@ server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id
733
733
  fields: z
734
734
  .array(z.enum(PLAN_FIELD_GROUPS))
735
735
  .optional()
736
- .describe("Opt-in field-GROUPS: cards, records (every kind) or records:goal/records:rule/records:caveat (one kind), architecture, sessions. Absent/empty = cheap scalars only (plan, boards, cardCount, bucketCounts) plus session state."),
736
+ .describe("Opt-in field-GROUPS: cards, records (every kind) or records:goal/records:rule/records:caveat (one kind), architecture, sessions, notes, events. Absent/empty = cheap scalars only (plan, boards, cardCount, bucketCounts) plus session state."),
737
737
  cards_offset: z
738
738
  .number()
739
739
  .int()
@@ -747,6 +747,31 @@ server.tool("plan_get", "Read a plan via GET /api/plans (DX-2683). Pass `plan_id
747
747
  .max(LIST_PAGE_MAX_LIMIT)
748
748
  .optional()
749
749
  .describe("How many cards one page holds, 1.." + LIST_PAGE_MAX_LIMIT + " (default " + PLAN_GET_CARDS_DEFAULT_LIMIT + "). Requires `fields` to include `cards`."),
750
+ events_limit: z
751
+ .number()
752
+ .int()
753
+ .positive()
754
+ .max(PLAN_GET_EVENTS_MAX_LIMIT)
755
+ .optional()
756
+ .describe("DX-3027 — how many ledger events one page holds, 1.." + PLAN_GET_EVENTS_MAX_LIMIT + " (default " + PLAN_GET_EVENTS_DEFAULT_LIMIT + "). Requires `fields` to include `events`."),
757
+ events_before: z
758
+ .string()
759
+ .min(1)
760
+ .optional()
761
+ .describe("DX-3027 — an opaque cursor from a previous page's `next_cursor`. Omit for the newest page. Requires `fields` to include `events`."),
762
+ events_kinds: z
763
+ .array(z.enum(PLAN_EVENT_KINDS))
764
+ .optional()
765
+ .describe("DX-3027 — only these event kinds. Omit for every kind. Requires `fields` to include `events`."),
766
+ events_origin: z
767
+ .enum(PLAN_EVENT_ORIGINS)
768
+ .optional()
769
+ .describe("DX-3027 — only events with this origin. Omit for every origin. Requires `fields` to include `events`."),
770
+ events_writer: z
771
+ .string()
772
+ .min(1)
773
+ .optional()
774
+ .describe("DX-3027 — only events with this exact writer name. Omit for every writer. Requires `fields` to include `events`."),
750
775
  }, async (args) => jsonResult(await planGet(client, args)));
751
776
  server.tool("plan_create", "Create a new, empty plan via POST /api/plans (DX-2531). GLOBAL — a plan is not board-scoped, and this call adds no cards, no records, and no architecture sections; it does NOT connect any session to the new plan (call `plan_connect` separately, exactly as adding a card to a plan is its own separate step). Returns `{ok, status, body: {plan: {id, ref, name, createdAt}}}` — `ref` is the plan's short reference (`PLN-<id>`). Use the returned `plan.id` with `plan_connect` to start working on it, or with `plan_get({plan_id})` to browse it.", {
752
777
  name: z.string().min(1).describe("The plan's name — shown in the Plans list."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.92",
3
+ "version": "0.1.93",
4
4
  "description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,51 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { DashboardHttpClient } from "./http-client.js";
3
- import { issueList, issueTransition } from "./handlers.js";
4
- /**
5
- * `issue_list` query-param forwarding. A fake `fetch` captures the built
6
- * URL so each filter is asserted at the wire, through the real client's
7
- * URL builder (`?repo=` always stamped, extra query merged after).
8
- */
9
- function clientCapturing() {
10
- const urls = [];
11
- const fetchImpl = (async (url) => {
12
- urls.push(url);
13
- return new Response(JSON.stringify({ issues: [] }), { status: 200 });
14
- });
15
- const client = new DashboardHttpClient({ baseUrl: "http://localhost:5555", repo: "danxbot", token: "t" }, fetchImpl);
16
- return { client, urls };
17
- }
18
- describe("issueList — query forwarding", () => {
19
- it("forwards q as the server-side search needle", async () => {
20
- const { client, urls } = clientCapturing();
21
- await issueList(client, { q: "retire" });
22
- expect(urls[0]).toContain("q=retire");
23
- });
24
- it("omits q when not provided", async () => {
25
- const { client, urls } = clientCapturing();
26
- await issueList(client, { include_closed: true });
27
- expect(urls[0]).not.toContain("q=");
28
- expect(urls[0]).toContain("include_closed=true");
29
- });
30
- });
31
- describe("issueTransition — body forwarding", () => {
32
- function clientCapturingBody() {
33
- const bodies = [];
34
- const fetchImpl = (async (_url, init) => {
35
- bodies.push(JSON.parse(String(init?.body ?? "{}")));
36
- return new Response(JSON.stringify({ issue: {} }), { status: 200 });
37
- });
38
- const client = new DashboardHttpClient({ baseUrl: "http://localhost:5555", repo: "danxbot", token: "t" }, fetchImpl);
39
- return { client, bodies };
40
- }
41
- it("forwards manual: true on pickup (DX-946 operator self-pickup)", async () => {
42
- const { client, bodies } = clientCapturingBody();
43
- await issueTransition(client, { id: "DX-1", action: "pickup", manual: true });
44
- expect(bodies[0]).toEqual({ action: "pickup", manual: true });
45
- });
46
- it("omits manual when not provided", async () => {
47
- const { client, bodies } = clientCapturingBody();
48
- await issueTransition(client, { id: "DX-1", action: "pickup" });
49
- expect(bodies[0]).toEqual({ action: "pickup" });
50
- });
51
- });