@nanobpm/urban 0.88.1 → 0.89.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.
@@ -198,6 +198,10 @@ export type UserTaskState = "CREATED" | "COMPLETED" | "CANCELED" | "FAILED";
198
198
  export interface ProcessInstanceSnapshot {
199
199
  readonly processInstanceKey: string;
200
200
  readonly state: ProcessInstanceState;
201
+ /** The key of the deployed process definition this instance runs — the handle a caller feeds to
202
+ * {@link EngineClient.getProcessDefinitionXml} to fetch the deployed BPMN (the instance →
203
+ * `processDefinitionKey` → deployed XML path, no repo checkout). Absent when the engine omits it. */
204
+ readonly processDefinitionKey?: string;
201
205
  }
202
206
  /**
203
207
  * A single open user task as {@link EngineClient.searchUserTasks} reports it. Carries the
@@ -425,6 +429,72 @@ export interface FormSchema {
425
429
  readonly version?: number;
426
430
  readonly schema: Record<string, unknown>;
427
431
  }
432
+ /**
433
+ * Selectors for a variable search: which process instance and/or scope to match, optionally
434
+ * narrowed to a single variable `name`. Used by {@link EngineClient.searchVariables}. An
435
+ * eventually consistent (zero-wait) read; an unset selector matches every variable in scope.
436
+ */
437
+ export interface VariableFilter {
438
+ processInstanceKey?: string;
439
+ scopeKey?: string;
440
+ name?: string;
441
+ }
442
+ /**
443
+ * A single process variable as {@link EngineClient.searchVariables} reports it — the read
444
+ * counterpart to the write-only {@link EngineClient.setVariables}. Mirrors the engine's v2
445
+ * `/variables/search` row: the variable's own `variableKey`, its `name`, its serialized JSON
446
+ * `value` (a string — an object/array value arrives JSON-encoded, so a caller `JSON.parse`s it),
447
+ * the `scopeKey` it is directly defined in (a process-instance key for a process-level variable,
448
+ * an element-instance key for a local one) and the owning `processInstanceKey`. `isTruncated` is
449
+ * `true` when the engine clipped a large `value` for the read model — the caller then knows the
450
+ * `value` is a preview, not the whole payload. This is the only way to read a parked token's
451
+ * business payload (`prKey`, `round`, an escalation `question`/`answer`, `resolution`,
452
+ * `directive`); the ADR 0065 projections carry lifecycle/position, not the payload.
453
+ */
454
+ export interface VariableSummary {
455
+ readonly variableKey: string;
456
+ readonly name: string;
457
+ readonly value: string;
458
+ readonly scopeKey: string;
459
+ readonly processInstanceKey: string;
460
+ readonly isTruncated: boolean;
461
+ }
462
+ /**
463
+ * Selectors for a job search: which process instance, lifecycle `state`, job `type`, `elementId`,
464
+ * and/or leasing `worker` to match. Used by {@link EngineClient.searchJobs}. `state` is kept a
465
+ * transport-agnostic bare string (the engine's job-state enum is broad and forward-looking — e.g.
466
+ * `CREATED`/`COMPLETED`/`FAILED`/`TIMED_OUT`), so a new engine state flows through without having
467
+ * to be enumerated here. An eventually consistent (zero-wait) read; an unset selector matches
468
+ * every job.
469
+ */
470
+ export interface JobFilter {
471
+ processInstanceKey?: string;
472
+ state?: string;
473
+ type?: string;
474
+ elementId?: string;
475
+ worker?: string;
476
+ }
477
+ /**
478
+ * A single job as {@link EngineClient.searchJobs} reports it — mirrors the engine's v2
479
+ * `/jobs/search` row. Carries the `jobKey` a caller passes to
480
+ * {@link EngineClient.updateJobRetries} (the only source of a `jobKey` for a job NOT already
481
+ * behind a `jobNoRetries` incident, which {@link searchIncidents} surfaces), the job `type` and
482
+ * lifecycle `state` (a bare string — see {@link JobFilter}), the owning `processInstanceKey`, and
483
+ * the best-effort diagnostics `worker`/`retries`/`elementId`/`deadline`. The `worker` field is the
484
+ * "is it actually stuck" signal: a `CREATED` job WITH a `worker` set has been leased by an agent,
485
+ * while one with NONE set is merely queued — so `worker` is omitted (absent) when unset. `retries`,
486
+ * `elementId`, and `deadline` are likewise present when the engine reports them, omitted otherwise.
487
+ */
488
+ export interface JobSummary {
489
+ readonly jobKey: string;
490
+ readonly type: string;
491
+ readonly state: string;
492
+ readonly processInstanceKey: string;
493
+ readonly worker?: string;
494
+ readonly retries?: number;
495
+ readonly elementId?: string;
496
+ readonly deadline?: string;
497
+ }
428
498
  /**
429
499
  * EngineClient is the seam onto a Nano engine. The SDK/REST-backed adapter implements
430
500
  * it against a live engine; tests implement it in-memory. Core modules depend only on this.
@@ -560,6 +630,40 @@ export interface EngineClient {
560
630
  * they answer "how far has this instance progressed", this answers "why is it stuck".
561
631
  */
562
632
  searchIncidents(filter?: IncidentFilter): Promise<IncidentSummary[]>;
633
+ /**
634
+ * Search process variables by owning process instance and/or scope, optionally narrowed to a
635
+ * single `name` (`POST /v2/variables/search`). The read counterpart to the write-only
636
+ * {@link setVariables}: it is the only way to see a parked token's business payload (`prKey`,
637
+ * `round`, an escalation `question`/`answer`, `resolution`, `directive`) — the ADR 0065
638
+ * projections carry lifecycle/position, not the payload. An eventually consistent (zero-wait)
639
+ * read; each {@link VariableSummary} carries the variable's serialized JSON `value` (a string)
640
+ * and an `isTruncated` flag set when the engine clipped a large value for the read model.
641
+ */
642
+ searchVariables(filter?: VariableFilter): Promise<VariableSummary[]>;
643
+ /**
644
+ * Search jobs by owning process instance, lifecycle `state`, job `type`, `elementId`, and/or
645
+ * leasing `worker` (`POST /v2/jobs/search`). This is the "is it actually stuck" read: a
646
+ * `CREATED` job WITH a `worker` set has been leased by an agent, while one with NONE set is
647
+ * merely queued. It is also the on-tool source of a `jobKey` for {@link updateJobRetries} when
648
+ * the job is NOT behind an incident — {@link searchIncidents} only surfaces a `jobKey` for a
649
+ * job that already faulted (a `jobNoRetries` incident), so retrying an un-incidented job would
650
+ * otherwise be impossible without a direct engine-API call. An eventually consistent (zero-wait)
651
+ * read; each {@link JobSummary} carries the `jobKey` plus best-effort `worker`/`retries`/
652
+ * `elementId`/`deadline` diagnostics.
653
+ */
654
+ searchJobs(filter?: JobFilter): Promise<JobSummary[]>;
655
+ /**
656
+ * Fetch the deployed BPMN XML of a process definition by its `processDefinitionKey`
657
+ * (`GET /v2/process-definitions/{processDefinitionKey}/xml`), or `null` when the key is blank,
658
+ * unknown (a 404), or the definition carries no XML. This is the *deployed* model — the routing
659
+ * source of truth, including its FEEL gateway conditions — so an agent can reason about WHY an
660
+ * instance routed where it did without checking out `resources/processes/*.bpmn` from the app
661
+ * repo. An instance already carries its `processDefinitionKey` (via {@link searchProcessInstances}),
662
+ * so the path is instance → `processDefinitionKey` → deployed XML. A read is treated as absence
663
+ * (`null`) rather than propagating (mirroring {@link getForm}/{@link getElementInstance}), so a
664
+ * caller distinguishes "have it" from "don't" without a try/catch. A zero-wait read.
665
+ */
666
+ getProcessDefinitionXml(processDefinitionKey: string): Promise<string | null>;
563
667
  /**
564
668
  * Resolve an open incident by key (`POST /v2/incidents/{incidentKey}/resolution`), unblocking
565
669
  * the parked token: a job incident returns the job to the activatable pool (so it must have
@@ -614,4 +718,4 @@ export interface EngineClient {
614
718
  * and `getForm` before it) that a purely structural `implements EngineClient` check no
615
719
  * longer catches once the fake is published/compiled against an older `@nanobpm/urban`.
616
720
  */
617
- export declare const ENGINE_CLIENT_METHODS: readonly ["deployResources", "createInstance", "cancelInstance", "publishMessage", "searchUserTasks", "openUserTasks", "getForm", "completeUserTask", "searchProcessInstances", "searchElementInstances", "searchElementInstanceWaitStates", "getElementInstance", "searchIncidents", "resolveIncident", "updateJobRetries", "setVariables", "registerWorker", "close"];
721
+ export declare const ENGINE_CLIENT_METHODS: readonly ["deployResources", "createInstance", "cancelInstance", "publishMessage", "searchUserTasks", "openUserTasks", "getForm", "completeUserTask", "searchProcessInstances", "searchElementInstances", "searchElementInstanceWaitStates", "getElementInstance", "searchIncidents", "searchVariables", "searchJobs", "getProcessDefinitionXml", "resolveIncident", "updateJobRetries", "setVariables", "registerWorker", "close"];
@@ -118,6 +118,9 @@ export const ENGINE_CLIENT_METHODS = [
118
118
  "searchElementInstanceWaitStates",
119
119
  "getElementInstance",
120
120
  "searchIncidents",
121
+ "searchVariables",
122
+ "searchJobs",
123
+ "getProcessDefinitionXml",
121
124
  "resolveIncident",
122
125
  "updateJobRetries",
123
126
  "setVariables",
@@ -536,6 +536,74 @@ function buildDebugTools(app) {
536
536
  return app.engine.searchIncidents(filter);
537
537
  },
538
538
  },
539
+ {
540
+ name: `${DEBUG_PREFIX}search_variables`,
541
+ description: "Engine truth: search process variables by process instance and/or scope, optionally by name — the read counterpart to set_variables. Reads only, eventually consistent. This is the only way to see a parked token's business payload (prKey, round, an escalation question/answer, resolution, directive); the projections carry lifecycle/position, not the payload. Each result carries the variableKey/name/serialized-JSON value/scopeKey/processInstanceKey/isTruncated.",
542
+ inputSchema: {
543
+ type: "object",
544
+ properties: {
545
+ processInstanceKey: OPTIONAL_STRING,
546
+ scopeKey: OPTIONAL_STRING,
547
+ name: OPTIONAL_STRING,
548
+ },
549
+ },
550
+ run: (args) => {
551
+ const filter = {};
552
+ const pik = readPresentString(args, "processInstanceKey");
553
+ if (pik)
554
+ filter.processInstanceKey = pik;
555
+ const scopeKey = readPresentString(args, "scopeKey");
556
+ if (scopeKey)
557
+ filter.scopeKey = scopeKey;
558
+ const name = readPresentString(args, "name");
559
+ if (name)
560
+ filter.name = name;
561
+ return app.engine.searchVariables(filter);
562
+ },
563
+ },
564
+ {
565
+ name: `${DEBUG_PREFIX}search_jobs`,
566
+ description: "Engine truth: search jobs by process instance, state, and/or type/elementId/worker. Reads only, eventually consistent. The 'is it actually stuck?' test: a CREATED job WITH a worker set has been leased by an agent, one with NONE set is merely queued. This is also the on-tool source of a jobKey for retry_job (the only other source is a jobNoRetries incident via search_incidents). Each result carries jobKey/type/state/processInstanceKey and the best-effort worker/retries/elementId/deadline.",
567
+ inputSchema: {
568
+ type: "object",
569
+ properties: {
570
+ processInstanceKey: OPTIONAL_STRING,
571
+ state: OPTIONAL_STRING,
572
+ type: OPTIONAL_STRING,
573
+ elementId: OPTIONAL_STRING,
574
+ worker: OPTIONAL_STRING,
575
+ },
576
+ },
577
+ run: (args) => {
578
+ const filter = {};
579
+ const pik = readPresentString(args, "processInstanceKey");
580
+ if (pik)
581
+ filter.processInstanceKey = pik;
582
+ const state = readPresentString(args, "state");
583
+ if (state)
584
+ filter.state = state;
585
+ const type = readPresentString(args, "type");
586
+ if (type)
587
+ filter.type = type;
588
+ const elementId = readPresentString(args, "elementId");
589
+ if (elementId)
590
+ filter.elementId = elementId;
591
+ const worker = readPresentString(args, "worker");
592
+ if (worker)
593
+ filter.worker = worker;
594
+ return app.engine.searchJobs(filter);
595
+ },
596
+ },
597
+ {
598
+ name: `${DEBUG_PREFIX}get_process_definition_xml`,
599
+ description: "Engine truth: return the deployed BPMN XML of a process definition by key (the routing source of truth, with its FEEL gateway conditions) — reason about WHY an instance routed where it did without a repo checkout. Reads only; null when the key is unknown. An instance carries its processDefinitionKey via search_process_instances, so the path is instance → processDefinitionKey → deployed XML; pair with get_element_instance to locate the parked element.",
600
+ inputSchema: {
601
+ type: "object",
602
+ properties: { processDefinitionKey: OPTIONAL_STRING },
603
+ required: ["processDefinitionKey"],
604
+ },
605
+ run: (args) => app.engine.getProcessDefinitionXml(requireString(args, "processDefinitionKey")),
606
+ },
539
607
  {
540
608
  name: `${DEBUG_PREFIX}cancel_instance`,
541
609
  description: "MUTATING: cancel a running process instance. Requires the shared-secret guard.",
@@ -1,4 +1,4 @@
1
- import type { EngineClient, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, FormSchema, IncidentFilter, IncidentState, IncidentSummary, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, UserTaskFilter, WorkerSubscription } from "../core/host.ts";
1
+ import type { EngineClient, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, FormSchema, IncidentFilter, IncidentState, IncidentSummary, JobFilter, JobHandler, JobSummary, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, UserTaskFilter, VariableFilter, VariableSummary, WorkerSubscription } from "../core/host.ts";
2
2
  import type { EngineSdkClient } from "./sdk.ts";
3
3
  /** Coerce an engine response's process-instance key to a non-empty string, or
4
4
  * throw — a missing key means a malformed/partial response, not a real instance. */
@@ -40,6 +40,21 @@ export declare function mapElementInstanceWaitStateRow(row: Record<string, unkno
40
40
  * `errorMessage` are best-effort diagnostics: present when the engine reports them, omitted
41
41
  * otherwise. The single source of truth `searchIncidents` extracts through. */
42
42
  export declare function mapIncidentRow(row: Record<string, unknown>, log: Log): IncidentSummary | undefined;
43
+ /** Map one engine variable row onto a {@link VariableSummary}, or `undefined` when the row is
44
+ * malformed (missing the `variableKey`, owning `processInstanceKey`, or `name` — the identity a
45
+ * caller needs). `value` is the engine's serialized JSON value (a string); a non-string value is
46
+ * JSON-encoded so the field is always a string a caller can `JSON.parse`. `scopeKey` falls back to
47
+ * the process-instance key for a process-level variable that omits it. `isTruncated` is the
48
+ * engine's large-value-clipped flag, defaulting to `false`. The single source of truth
49
+ * `searchVariables` extracts through. */
50
+ export declare function mapVariableRow(row: Record<string, unknown>, log: Log): VariableSummary | undefined;
51
+ /** Map one engine job row onto a {@link JobSummary}, or `undefined` when the row is malformed
52
+ * (missing the `jobKey`, `type`, or owning `processInstanceKey`). `worker`/`retries`/`elementId`/
53
+ * `deadline` are best-effort diagnostics: present when the engine reports them, omitted otherwise
54
+ * — an unset `worker` is the "queued, not leased" signal, so it must be absent rather than `""`.
55
+ * `state` is passed through as a bare string (the engine's job-state enum is broad). The single
56
+ * source of truth `searchJobs` extracts through. */
57
+ export declare function mapJobRow(row: Record<string, unknown>, log: Log): JobSummary | undefined;
43
58
  /** A job as delivered to a nano-sdk job handler: the frame fields plus the
44
59
  * acknowledgement actions the handler must call. */
45
60
  export interface NanoSdkActivatedJob {
@@ -135,6 +150,17 @@ export interface NanoSdkClient {
135
150
  filter?: Record<string, unknown>;
136
151
  page?: Record<string, unknown>;
137
152
  }, consistency?: unknown, options?: unknown): Promise<Record<string, unknown>>;
153
+ searchVariables(input: {
154
+ filter?: Record<string, unknown>;
155
+ page?: Record<string, unknown>;
156
+ }, consistency?: unknown, options?: unknown): Promise<Record<string, unknown>>;
157
+ searchJobs(input: {
158
+ filter?: Record<string, unknown>;
159
+ page?: Record<string, unknown>;
160
+ }, consistency?: unknown, options?: unknown): Promise<Record<string, unknown>>;
161
+ getProcessDefinitionXml(input: {
162
+ processDefinitionKey: string;
163
+ }, consistency?: unknown, options?: unknown): Promise<unknown>;
138
164
  resolveIncident(input: {
139
165
  incidentKey: string;
140
166
  }, options?: unknown): Promise<unknown>;
@@ -215,6 +241,9 @@ export declare class SdkEngineClient implements EngineClient {
215
241
  searchElementInstanceWaitStates(filter?: ElementInstanceWaitStateFilter): Promise<ElementInstanceWaitState[]>;
216
242
  getElementInstance(elementInstanceKey: string): Promise<ElementInstanceSummary | null>;
217
243
  searchIncidents(filter?: IncidentFilter): Promise<IncidentSummary[]>;
244
+ searchVariables(filter?: VariableFilter): Promise<VariableSummary[]>;
245
+ searchJobs(filter?: JobFilter): Promise<JobSummary[]>;
246
+ getProcessDefinitionXml(processDefinitionKey: string): Promise<string | null>;
218
247
  resolveIncident(input: {
219
248
  incidentKey: string;
220
249
  }): Promise<void>;
@@ -320,6 +320,91 @@ export function mapIncidentRow(row, log) {
320
320
  state: normalizeIncidentState(row.state),
321
321
  };
322
322
  }
323
+ /** Map one engine variable row onto a {@link VariableSummary}, or `undefined` when the row is
324
+ * malformed (missing the `variableKey`, owning `processInstanceKey`, or `name` — the identity a
325
+ * caller needs). `value` is the engine's serialized JSON value (a string); a non-string value is
326
+ * JSON-encoded so the field is always a string a caller can `JSON.parse`. `scopeKey` falls back to
327
+ * the process-instance key for a process-level variable that omits it. `isTruncated` is the
328
+ * engine's large-value-clipped flag, defaulting to `false`. The single source of truth
329
+ * `searchVariables` extracts through. */
330
+ export function mapVariableRow(row, log) {
331
+ const variableKey = presentEngineKey(row.variableKey);
332
+ if (variableKey === undefined) {
333
+ log("warn", "skipping variable with no variableKey in engine response");
334
+ return undefined;
335
+ }
336
+ const processInstanceKey = presentEngineKey(row.processInstanceKey);
337
+ if (processInstanceKey === undefined) {
338
+ log("warn", "skipping variable with no processInstanceKey in engine response", {
339
+ variableKey,
340
+ });
341
+ return undefined;
342
+ }
343
+ const name = presentText(row.name);
344
+ if (name === undefined) {
345
+ log("warn", "skipping variable with no name in engine response", { variableKey });
346
+ return undefined;
347
+ }
348
+ // A process-level variable is scoped to the process instance itself; the engine may omit
349
+ // `scopeKey` for it, so fall back to the process-instance key rather than dropping the row.
350
+ const scopeKey = presentEngineKey(row.scopeKey) ?? processInstanceKey;
351
+ // The engine serializes `value` as a JSON string. Keep a string as-is; encode any other type
352
+ // (an object/array/number a permissive engine might inline) so the field is always a string the
353
+ // caller can `JSON.parse`.
354
+ const value = typeof row.value === "string" ? row.value : JSON.stringify(row.value ?? null);
355
+ return {
356
+ variableKey,
357
+ name,
358
+ value,
359
+ scopeKey,
360
+ processInstanceKey,
361
+ isTruncated: row.isTruncated === true,
362
+ };
363
+ }
364
+ /** Map one engine job row onto a {@link JobSummary}, or `undefined` when the row is malformed
365
+ * (missing the `jobKey`, `type`, or owning `processInstanceKey`). `worker`/`retries`/`elementId`/
366
+ * `deadline` are best-effort diagnostics: present when the engine reports them, omitted otherwise
367
+ * — an unset `worker` is the "queued, not leased" signal, so it must be absent rather than `""`.
368
+ * `state` is passed through as a bare string (the engine's job-state enum is broad). The single
369
+ * source of truth `searchJobs` extracts through. */
370
+ export function mapJobRow(row, log) {
371
+ const jobKey = presentEngineKey(row.jobKey);
372
+ if (jobKey === undefined) {
373
+ log("warn", "skipping job with no jobKey in engine response");
374
+ return undefined;
375
+ }
376
+ const type = presentText(row.type);
377
+ if (type === undefined) {
378
+ log("warn", "skipping job with no type in engine response", { jobKey });
379
+ return undefined;
380
+ }
381
+ const processInstanceKey = presentEngineKey(row.processInstanceKey);
382
+ if (processInstanceKey === undefined) {
383
+ log("warn", "skipping job with no processInstanceKey in engine response", { jobKey });
384
+ return undefined;
385
+ }
386
+ const state = presentText(row.state);
387
+ if (state === undefined) {
388
+ log("warn", "skipping job with no state in engine response", { jobKey });
389
+ return undefined;
390
+ }
391
+ const worker = presentText(row.worker);
392
+ const retries = typeof row.retries === "number" && Number.isFinite(row.retries)
393
+ ? row.retries
394
+ : undefined;
395
+ const elementId = presentText(row.elementId);
396
+ const deadline = presentText(row.deadline);
397
+ return {
398
+ jobKey,
399
+ type,
400
+ state,
401
+ processInstanceKey,
402
+ ...(worker ? { worker } : {}),
403
+ ...(retries !== undefined ? { retries } : {}),
404
+ ...(elementId ? { elementId } : {}),
405
+ ...(deadline ? { deadline } : {}),
406
+ };
407
+ }
323
408
  /**
324
409
  * An `EngineClient` backed entirely by a `@nanobpm/nano-sdk` client. Deploy, create,
325
410
  * message, and user-task calls map one-to-one onto the SDK's client methods; workers
@@ -484,7 +569,12 @@ export class SdkEngineClient {
484
569
  });
485
570
  return [];
486
571
  }
487
- return [{ processInstanceKey: String(key), state }];
572
+ const processDefinitionKey = presentEngineKey(it.processDefinitionKey);
573
+ return [{
574
+ processInstanceKey: String(key),
575
+ state,
576
+ ...(processDefinitionKey ? { processDefinitionKey } : {}),
577
+ }];
488
578
  });
489
579
  }
490
580
  async searchElementInstances(filter) {
@@ -575,6 +665,78 @@ export class SdkEngineClient {
575
665
  return mapped ? [mapped] : [];
576
666
  });
577
667
  }
668
+ async searchVariables(filter) {
669
+ // Same shape as `searchIncidents`: build the engine filter from the transport-agnostic
670
+ // selectors, read at zero-wait consistency (a variable search is eventually consistent), and
671
+ // map each row through the single `mapVariableRow` gate, dropping malformed rows.
672
+ const f = {};
673
+ const processInstanceKey = presentEngineKey(filter?.processInstanceKey);
674
+ if (processInstanceKey)
675
+ f.processInstanceKey = processInstanceKey;
676
+ const scopeKey = presentEngineKey(filter?.scopeKey);
677
+ if (scopeKey)
678
+ f.scopeKey = scopeKey;
679
+ const name = filter?.name?.trim();
680
+ if (name)
681
+ f.name = name;
682
+ const body = await this.client.searchVariables({ filter: f }, { consistency: { waitUpToMs: 0 } });
683
+ const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
684
+ return items.flatMap((it) => {
685
+ const mapped = mapVariableRow(it, this.log);
686
+ return mapped ? [mapped] : [];
687
+ });
688
+ }
689
+ async searchJobs(filter) {
690
+ // Same shape as `searchIncidents`: build the engine filter from the transport-agnostic
691
+ // selectors, read at zero-wait consistency (a job search is eventually consistent), and map
692
+ // each row through the single `mapJobRow` gate, dropping malformed rows.
693
+ const f = {};
694
+ const processInstanceKey = presentEngineKey(filter?.processInstanceKey);
695
+ if (processInstanceKey)
696
+ f.processInstanceKey = processInstanceKey;
697
+ const state = filter?.state?.trim();
698
+ if (state)
699
+ f.state = state;
700
+ const type = filter?.type?.trim();
701
+ if (type)
702
+ f.type = type;
703
+ const elementId = filter?.elementId?.trim();
704
+ if (elementId)
705
+ f.elementId = elementId;
706
+ const worker = filter?.worker?.trim();
707
+ if (worker)
708
+ f.worker = worker;
709
+ const body = await this.client.searchJobs({ filter: f }, { consistency: { waitUpToMs: 0 } });
710
+ const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
711
+ return items.flatMap((it) => {
712
+ const mapped = mapJobRow(it, this.log);
713
+ return mapped ? [mapped] : [];
714
+ });
715
+ }
716
+ async getProcessDefinitionXml(processDefinitionKey) {
717
+ // A blank key can never address a definition — short-circuit to null rather than issue a
718
+ // `GET /v2/process-definitions//xml` with an empty segment. Normalize a padded-but-valid key so
719
+ // `" 5 "` addresses the same definition, mirroring `getElementInstance`.
720
+ const key = presentEngineKey(processDefinitionKey);
721
+ if (key === undefined)
722
+ return null;
723
+ let xml;
724
+ try {
725
+ xml = await this.client.getProcessDefinitionXml({ processDefinitionKey: key }, { consistency: { waitUpToMs: 0 } });
726
+ }
727
+ catch (err) {
728
+ // A 404 (no such definition) is an expected "not found", not a fault — mirror `getForm`/
729
+ // `getElementInstance`, which treat a failed fetch as absence (null) rather than propagating.
730
+ this.log("warn", "getProcessDefinitionXml: engine fetch failed", {
731
+ processDefinitionKey: key,
732
+ err: err instanceof Error ? err.message : String(err),
733
+ });
734
+ return null;
735
+ }
736
+ // The endpoint returns the raw BPMN XML as a string (204 → empty). Treat a blank/absent body
737
+ // as "no XML" (null) so a caller distinguishes "have it" from "don't".
738
+ return typeof xml === "string" && xml.trim() !== "" ? xml : null;
739
+ }
578
740
  async resolveIncident(input) {
579
741
  await this.client.resolveIncident({
580
742
  incidentKey: requireEngineKey(input.incidentKey, "incidentKey"),
@@ -8,7 +8,7 @@ export { bindModeToHost, expandEnv, expandEnvString, isBindMode, loadManifest, p
8
8
  export type { ActionDecl, AppManifest, BindMode, ChatSurface, DataSource, DomainField, DomainType, InstanceTracking, LlmBinding, NetworkConfig, PagesSurface, Surfaces, TaskInboxSurface, Trigger, Worker, } from "./core/manifest.ts";
9
9
  export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.ts";
10
10
  export type { ValidationIssue } from "./core/validate.ts";
11
- export type { EngineClient, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, EngineJob, FormSchema, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, IncidentState, EngineIncidentState, IncidentFilter, IncidentSummary, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb, UserTaskState, UserTaskSummary, UserTaskFilter, WatchHandle, WorkerSubscription, } from "./core/host.ts";
11
+ export type { EngineClient, ElementInstanceState, ElementInstanceSummary, ElementInstanceFilter, ElementInstanceWaitState, ElementInstanceWaitStateFilter, WaitStateType, EngineJob, FormSchema, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, IncidentState, EngineIncidentState, IncidentFilter, IncidentSummary, JobFilter, JobHandler, JobSummary, ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb, UserTaskState, UserTaskSummary, UserTaskFilter, VariableFilter, VariableSummary, WatchHandle, WorkerSubscription, } from "./core/host.ts";
12
12
  export { buildFormSchema, parseFormSchema, pickFormLinkage, presentFormIdentifier, resolveFormIdentifier, } from "./core/form-contract.ts";
13
13
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
14
14
  export { createLogger } from "./core/logger.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.88.1",
3
+ "version": "0.89.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -229,6 +229,10 @@ export type UserTaskState = "CREATED" | "COMPLETED" | "CANCELED" | "FAILED";
229
229
  export interface ProcessInstanceSnapshot {
230
230
  readonly processInstanceKey: string;
231
231
  readonly state: ProcessInstanceState;
232
+ /** The key of the deployed process definition this instance runs — the handle a caller feeds to
233
+ * {@link EngineClient.getProcessDefinitionXml} to fetch the deployed BPMN (the instance →
234
+ * `processDefinitionKey` → deployed XML path, no repo checkout). Absent when the engine omits it. */
235
+ readonly processDefinitionKey?: string;
232
236
  }
233
237
 
234
238
  /**
@@ -487,6 +491,76 @@ export interface FormSchema {
487
491
  readonly schema: Record<string, unknown>;
488
492
  }
489
493
 
494
+ /**
495
+ * Selectors for a variable search: which process instance and/or scope to match, optionally
496
+ * narrowed to a single variable `name`. Used by {@link EngineClient.searchVariables}. An
497
+ * eventually consistent (zero-wait) read; an unset selector matches every variable in scope.
498
+ */
499
+ export interface VariableFilter {
500
+ processInstanceKey?: string;
501
+ scopeKey?: string;
502
+ name?: string;
503
+ }
504
+
505
+ /**
506
+ * A single process variable as {@link EngineClient.searchVariables} reports it — the read
507
+ * counterpart to the write-only {@link EngineClient.setVariables}. Mirrors the engine's v2
508
+ * `/variables/search` row: the variable's own `variableKey`, its `name`, its serialized JSON
509
+ * `value` (a string — an object/array value arrives JSON-encoded, so a caller `JSON.parse`s it),
510
+ * the `scopeKey` it is directly defined in (a process-instance key for a process-level variable,
511
+ * an element-instance key for a local one) and the owning `processInstanceKey`. `isTruncated` is
512
+ * `true` when the engine clipped a large `value` for the read model — the caller then knows the
513
+ * `value` is a preview, not the whole payload. This is the only way to read a parked token's
514
+ * business payload (`prKey`, `round`, an escalation `question`/`answer`, `resolution`,
515
+ * `directive`); the ADR 0065 projections carry lifecycle/position, not the payload.
516
+ */
517
+ export interface VariableSummary {
518
+ readonly variableKey: string;
519
+ readonly name: string;
520
+ readonly value: string;
521
+ readonly scopeKey: string;
522
+ readonly processInstanceKey: string;
523
+ readonly isTruncated: boolean;
524
+ }
525
+
526
+ /**
527
+ * Selectors for a job search: which process instance, lifecycle `state`, job `type`, `elementId`,
528
+ * and/or leasing `worker` to match. Used by {@link EngineClient.searchJobs}. `state` is kept a
529
+ * transport-agnostic bare string (the engine's job-state enum is broad and forward-looking — e.g.
530
+ * `CREATED`/`COMPLETED`/`FAILED`/`TIMED_OUT`), so a new engine state flows through without having
531
+ * to be enumerated here. An eventually consistent (zero-wait) read; an unset selector matches
532
+ * every job.
533
+ */
534
+ export interface JobFilter {
535
+ processInstanceKey?: string;
536
+ state?: string;
537
+ type?: string;
538
+ elementId?: string;
539
+ worker?: string;
540
+ }
541
+
542
+ /**
543
+ * A single job as {@link EngineClient.searchJobs} reports it — mirrors the engine's v2
544
+ * `/jobs/search` row. Carries the `jobKey` a caller passes to
545
+ * {@link EngineClient.updateJobRetries} (the only source of a `jobKey` for a job NOT already
546
+ * behind a `jobNoRetries` incident, which {@link searchIncidents} surfaces), the job `type` and
547
+ * lifecycle `state` (a bare string — see {@link JobFilter}), the owning `processInstanceKey`, and
548
+ * the best-effort diagnostics `worker`/`retries`/`elementId`/`deadline`. The `worker` field is the
549
+ * "is it actually stuck" signal: a `CREATED` job WITH a `worker` set has been leased by an agent,
550
+ * while one with NONE set is merely queued — so `worker` is omitted (absent) when unset. `retries`,
551
+ * `elementId`, and `deadline` are likewise present when the engine reports them, omitted otherwise.
552
+ */
553
+ export interface JobSummary {
554
+ readonly jobKey: string;
555
+ readonly type: string;
556
+ readonly state: string;
557
+ readonly processInstanceKey: string;
558
+ readonly worker?: string;
559
+ readonly retries?: number;
560
+ readonly elementId?: string;
561
+ readonly deadline?: string;
562
+ }
563
+
490
564
  /**
491
565
  * EngineClient is the seam onto a Nano engine. The SDK/REST-backed adapter implements
492
566
  * it against a live engine; tests implement it in-memory. Core modules depend only on this.
@@ -616,6 +690,40 @@ export interface EngineClient {
616
690
  * they answer "how far has this instance progressed", this answers "why is it stuck".
617
691
  */
618
692
  searchIncidents(filter?: IncidentFilter): Promise<IncidentSummary[]>;
693
+ /**
694
+ * Search process variables by owning process instance and/or scope, optionally narrowed to a
695
+ * single `name` (`POST /v2/variables/search`). The read counterpart to the write-only
696
+ * {@link setVariables}: it is the only way to see a parked token's business payload (`prKey`,
697
+ * `round`, an escalation `question`/`answer`, `resolution`, `directive`) — the ADR 0065
698
+ * projections carry lifecycle/position, not the payload. An eventually consistent (zero-wait)
699
+ * read; each {@link VariableSummary} carries the variable's serialized JSON `value` (a string)
700
+ * and an `isTruncated` flag set when the engine clipped a large value for the read model.
701
+ */
702
+ searchVariables(filter?: VariableFilter): Promise<VariableSummary[]>;
703
+ /**
704
+ * Search jobs by owning process instance, lifecycle `state`, job `type`, `elementId`, and/or
705
+ * leasing `worker` (`POST /v2/jobs/search`). This is the "is it actually stuck" read: a
706
+ * `CREATED` job WITH a `worker` set has been leased by an agent, while one with NONE set is
707
+ * merely queued. It is also the on-tool source of a `jobKey` for {@link updateJobRetries} when
708
+ * the job is NOT behind an incident — {@link searchIncidents} only surfaces a `jobKey` for a
709
+ * job that already faulted (a `jobNoRetries` incident), so retrying an un-incidented job would
710
+ * otherwise be impossible without a direct engine-API call. An eventually consistent (zero-wait)
711
+ * read; each {@link JobSummary} carries the `jobKey` plus best-effort `worker`/`retries`/
712
+ * `elementId`/`deadline` diagnostics.
713
+ */
714
+ searchJobs(filter?: JobFilter): Promise<JobSummary[]>;
715
+ /**
716
+ * Fetch the deployed BPMN XML of a process definition by its `processDefinitionKey`
717
+ * (`GET /v2/process-definitions/{processDefinitionKey}/xml`), or `null` when the key is blank,
718
+ * unknown (a 404), or the definition carries no XML. This is the *deployed* model — the routing
719
+ * source of truth, including its FEEL gateway conditions — so an agent can reason about WHY an
720
+ * instance routed where it did without checking out `resources/processes/*.bpmn` from the app
721
+ * repo. An instance already carries its `processDefinitionKey` (via {@link searchProcessInstances}),
722
+ * so the path is instance → `processDefinitionKey` → deployed XML. A read is treated as absence
723
+ * (`null`) rather than propagating (mirroring {@link getForm}/{@link getElementInstance}), so a
724
+ * caller distinguishes "have it" from "don't" without a try/catch. A zero-wait read.
725
+ */
726
+ getProcessDefinitionXml(processDefinitionKey: string): Promise<string | null>;
619
727
  /**
620
728
  * Resolve an open incident by key (`POST /v2/incidents/{incidentKey}/resolution`), unblocking
621
729
  * the parked token: a job incident returns the job to the activatable pool (so it must have
@@ -680,6 +788,9 @@ export const ENGINE_CLIENT_METHODS = [
680
788
  "searchElementInstanceWaitStates",
681
789
  "getElementInstance",
682
790
  "searchIncidents",
791
+ "searchVariables",
792
+ "searchJobs",
793
+ "getProcessDefinitionXml",
683
794
  "resolveIncident",
684
795
  "updateJobRetries",
685
796
  "setVariables",
@@ -37,6 +37,9 @@ const engine: EngineClient = {
37
37
  searchElementInstanceWaitStates: async () => [],
38
38
  getElementInstance: async () => null,
39
39
  searchIncidents: async () => [],
40
+ searchVariables: async () => [],
41
+ searchJobs: async () => [],
42
+ getProcessDefinitionXml: async () => null,
40
43
  resolveIncident: async () => {},
41
44
  updateJobRetries: async () => {},
42
45
  setVariables: async () => {},
@@ -22,6 +22,9 @@ const fakeEngine: EngineClient = {
22
22
  searchElementInstanceWaitStates: async () => [],
23
23
  getElementInstance: async () => null,
24
24
  searchIncidents: async () => [],
25
+ searchVariables: async () => [],
26
+ searchJobs: async () => [],
27
+ getProcessDefinitionXml: async () => null,
25
28
  resolveIncident: async () => {},
26
29
  updateJobRetries: async () => {},
27
30
  setVariables: async () => {},
@@ -35,6 +35,9 @@ const engine: EngineClient = {
35
35
  searchElementInstanceWaitStates: async () => [],
36
36
  getElementInstance: async () => null,
37
37
  searchIncidents: async () => [],
38
+ searchVariables: async () => [],
39
+ searchJobs: async () => [],
40
+ getProcessDefinitionXml: async () => null,
38
41
  resolveIncident: async () => {},
39
42
  updateJobRetries: async () => {},
40
43
  setVariables: async () => {},
@@ -89,6 +89,9 @@ function cancelEngine(opts: {
89
89
  searchElementInstanceWaitStates: notUsed("searchElementInstanceWaitStates"),
90
90
  getElementInstance: notUsed("getElementInstance"),
91
91
  searchIncidents: notUsed("searchIncidents"),
92
+ searchVariables: notUsed("searchVariables"),
93
+ searchJobs: notUsed("searchJobs"),
94
+ getProcessDefinitionXml: notUsed("getProcessDefinitionXml"),
92
95
  resolveIncident: notUsed("resolveIncident"),
93
96
  updateJobRetries: notUsed("updateJobRetries"),
94
97
  setVariables: notUsed("setVariables"),
@@ -25,6 +25,9 @@ class MiniEngine implements EngineClient {
25
25
  async searchElementInstanceWaitStates(): Promise<[]> { return []; }
26
26
  async getElementInstance(): Promise<null> { return null; }
27
27
  async searchIncidents(): Promise<never[]> { return []; }
28
+ async searchVariables(): Promise<never[]> { return []; }
29
+ async searchJobs(): Promise<never[]> { return []; }
30
+ async getProcessDefinitionXml(): Promise<null> { return null; }
28
31
  async resolveIncident(): Promise<void> {}
29
32
  async updateJobRetries(): Promise<void> {}
30
33
  async setVariables(): Promise<void> {}
@@ -24,6 +24,9 @@ const noEngine: EngineClient = {
24
24
  searchElementInstanceWaitStates: async () => [],
25
25
  getElementInstance: async () => null,
26
26
  searchIncidents: async () => [],
27
+ searchVariables: async () => [],
28
+ searchJobs: async () => [],
29
+ getProcessDefinitionXml: async () => null,
27
30
  resolveIncident: async () => {},
28
31
  updateJobRetries: async () => {},
29
32
  setVariables: async () => {},
@@ -94,6 +94,9 @@ function fakeEngine(
94
94
  searchElementInstanceWaitStates: notUsed("searchElementInstanceWaitStates"),
95
95
  getElementInstance: notUsed("getElementInstance"),
96
96
  searchIncidents: notUsed("searchIncidents"),
97
+ searchVariables: notUsed("searchVariables"),
98
+ searchJobs: notUsed("searchJobs"),
99
+ getProcessDefinitionXml: notUsed("getProcessDefinitionXml"),
97
100
  resolveIncident: notUsed("resolveIncident"),
98
101
  updateJobRetries: notUsed("updateJobRetries"),
99
102
  setVariables: notUsed("setVariables"),
@@ -91,6 +91,9 @@ function fakeEngine(overrides: Partial<EngineClient> = {}): EngineClient {
91
91
  searchElementInstanceWaitStates: async () => [],
92
92
  getElementInstance: async () => null,
93
93
  searchIncidents: async () => [],
94
+ searchVariables: async () => [],
95
+ searchJobs: async () => [],
96
+ getProcessDefinitionXml: async () => null,
94
97
  resolveIncident: async () => {},
95
98
  updateJobRetries: async () => {},
96
99
  setVariables: async () => {},
@@ -565,6 +568,9 @@ test("the framework debug tools appear and return engine truth", async () => {
565
568
  assert.ok(names.includes("urban_debug_search_user_tasks"));
566
569
  assert.ok(names.includes("urban_debug_instance_state"));
567
570
  assert.ok(names.includes("urban_debug_open_user_tasks"));
571
+ assert.ok(names.includes("urban_debug_search_variables"));
572
+ assert.ok(names.includes("urban_debug_search_jobs"));
573
+ assert.ok(names.includes("urban_debug_get_process_definition_xml"));
568
574
 
569
575
  const call = await rpc(router, session, "tools/call", {
570
576
  name: "urban_debug_search_process_instances",
@@ -574,6 +580,97 @@ test("the framework debug tools appear and return engine truth", async () => {
574
580
  assert.deepEqual(JSON.parse(text), [snapshot]);
575
581
  });
576
582
 
583
+ test("urban_debug_search_variables / search_jobs / get_process_definition_xml are read tools returning engine truth", async () => {
584
+ const variable = {
585
+ variableKey: "v-1",
586
+ name: "prKey",
587
+ value: '"owner/repo#7"',
588
+ scopeKey: "pi-1",
589
+ processInstanceKey: "pi-1",
590
+ isTruncated: false,
591
+ };
592
+ const job = {
593
+ jobKey: "j-1",
594
+ type: "senior:pr-review",
595
+ state: "CREATED",
596
+ processInstanceKey: "pi-1",
597
+ retries: 3,
598
+ elementId: "review",
599
+ };
600
+ const seenVarFilters: unknown[] = [];
601
+ const seenJobFilters: unknown[] = [];
602
+ const seenXmlKeys: string[] = [];
603
+ const engine = fakeEngine({
604
+ searchVariables: async (filter) => {
605
+ seenVarFilters.push(filter);
606
+ return [variable];
607
+ },
608
+ searchJobs: async (filter) => {
609
+ seenJobFilters.push(filter);
610
+ return [job];
611
+ },
612
+ getProcessDefinitionXml: async (key) => {
613
+ seenXmlKeys.push(key);
614
+ return key === "pd-1" ? "<bpmn:definitions/>" : null;
615
+ },
616
+ });
617
+ const { router } = buildHarness({ engine });
618
+ const session = await connect(router);
619
+
620
+ // All three tools are read tools — they serve on loopback with no credential.
621
+ const vars = await rpc(router, session, "tools/call", {
622
+ name: "urban_debug_search_variables",
623
+ arguments: { processInstanceKey: "pi-1", name: "prKey" },
624
+ });
625
+ assert.notEqual(vars.result?.isError, true, "search_variables is a read tool (no credential needed)");
626
+ assert.deepEqual(JSON.parse(toolContentText(vars.result)), [variable]);
627
+ assert.deepEqual(seenVarFilters, [{ processInstanceKey: "pi-1", name: "prKey" }]);
628
+
629
+ const jobs = await rpc(router, session, "tools/call", {
630
+ name: "urban_debug_search_jobs",
631
+ arguments: { processInstanceKey: "pi-1", state: "CREATED" },
632
+ });
633
+ assert.notEqual(jobs.result?.isError, true, "search_jobs is a read tool (no credential needed)");
634
+ assert.deepEqual(JSON.parse(toolContentText(jobs.result)), [job]);
635
+ assert.deepEqual(seenJobFilters, [{ processInstanceKey: "pi-1", state: "CREATED" }]);
636
+
637
+ const xml = await rpc(router, session, "tools/call", {
638
+ name: "urban_debug_get_process_definition_xml",
639
+ arguments: { processDefinitionKey: "pd-1" },
640
+ });
641
+ assert.notEqual(xml.result?.isError, true, "get_process_definition_xml is a read tool");
642
+ // The deployed XML is a raw string, surfaced verbatim (not JSON-wrapped).
643
+ assert.equal(toolContentText(xml.result), "<bpmn:definitions/>");
644
+ assert.deepEqual(seenXmlKeys, ["pd-1"]);
645
+
646
+ // An unknown key returns typed-absent (null) rather than erroring.
647
+ const absent = await rpc(router, session, "tools/call", {
648
+ name: "urban_debug_get_process_definition_xml",
649
+ arguments: { processDefinitionKey: "nope" },
650
+ });
651
+ assert.equal(toolContentText(absent.result), "null");
652
+ });
653
+
654
+ test("the three new debug read tools expose self-contained ($ref-free) input schemas", async () => {
655
+ const { router } = buildHarness({ engine: fakeEngine() });
656
+ const session = await connect(router);
657
+ const list = await rpc(router, session, "tools/list", {});
658
+ const tools = list.result?.tools;
659
+ assert.ok(Array.isArray(tools));
660
+ const names = tools.map((t) => Reflect.get(t, "name"));
661
+ for (const name of [
662
+ "urban_debug_search_variables",
663
+ "urban_debug_search_jobs",
664
+ "urban_debug_get_process_definition_xml",
665
+ ]) {
666
+ assert.ok(names.includes(name), `${name} must appear in tools/list`);
667
+ const tool: unknown = tools[names.indexOf(name)];
668
+ const schema: unknown = Reflect.get(tool ?? {}, "inputSchema");
669
+ assert.equal(Reflect.get(schema ?? {}, "type"), "object", `${name} schema is an object`);
670
+ assert.ok(!JSON.stringify(schema).includes("$ref"), `${name} schema must be $ref-free`);
671
+ }
672
+ });
673
+
577
674
  test("an app operationId in the reserved urban_debug_ namespace can't shadow a framework tool", async () => {
578
675
  // A `urban_debug_*` operationId is a legal safe path segment, so an app could declare one. Because
579
676
  // `CallTool` resolves framework debug tools before app ops, an unreserved namespace would let such
@@ -69,10 +69,12 @@ import type {
69
69
  HttpRequest,
70
70
  HttpResponse,
71
71
  IncidentFilter,
72
+ JobFilter,
72
73
  ProcessInstanceState,
73
74
  SqliteDb,
74
75
  UserTaskFilter,
75
76
  UserTaskState,
77
+ VariableFilter,
76
78
  WaitStateType,
77
79
  } from "../host.ts";
78
80
  import { presentFormIdentifier } from "../host.ts";
@@ -640,6 +642,70 @@ function buildDebugTools(app: AppApi): DebugTool[] {
640
642
  return app.engine.searchIncidents(filter);
641
643
  },
642
644
  },
645
+ {
646
+ name: `${DEBUG_PREFIX}search_variables`,
647
+ description:
648
+ "Engine truth: search process variables by process instance and/or scope, optionally by name — the read counterpart to set_variables. Reads only, eventually consistent. This is the only way to see a parked token's business payload (prKey, round, an escalation question/answer, resolution, directive); the projections carry lifecycle/position, not the payload. Each result carries the variableKey/name/serialized-JSON value/scopeKey/processInstanceKey/isTruncated.",
649
+ inputSchema: {
650
+ type: "object",
651
+ properties: {
652
+ processInstanceKey: OPTIONAL_STRING,
653
+ scopeKey: OPTIONAL_STRING,
654
+ name: OPTIONAL_STRING,
655
+ },
656
+ },
657
+ run: (args) => {
658
+ const filter: VariableFilter = {};
659
+ const pik = readPresentString(args, "processInstanceKey");
660
+ if (pik) filter.processInstanceKey = pik;
661
+ const scopeKey = readPresentString(args, "scopeKey");
662
+ if (scopeKey) filter.scopeKey = scopeKey;
663
+ const name = readPresentString(args, "name");
664
+ if (name) filter.name = name;
665
+ return app.engine.searchVariables(filter);
666
+ },
667
+ },
668
+ {
669
+ name: `${DEBUG_PREFIX}search_jobs`,
670
+ description:
671
+ "Engine truth: search jobs by process instance, state, and/or type/elementId/worker. Reads only, eventually consistent. The 'is it actually stuck?' test: a CREATED job WITH a worker set has been leased by an agent, one with NONE set is merely queued. This is also the on-tool source of a jobKey for retry_job (the only other source is a jobNoRetries incident via search_incidents). Each result carries jobKey/type/state/processInstanceKey and the best-effort worker/retries/elementId/deadline.",
672
+ inputSchema: {
673
+ type: "object",
674
+ properties: {
675
+ processInstanceKey: OPTIONAL_STRING,
676
+ state: OPTIONAL_STRING,
677
+ type: OPTIONAL_STRING,
678
+ elementId: OPTIONAL_STRING,
679
+ worker: OPTIONAL_STRING,
680
+ },
681
+ },
682
+ run: (args) => {
683
+ const filter: JobFilter = {};
684
+ const pik = readPresentString(args, "processInstanceKey");
685
+ if (pik) filter.processInstanceKey = pik;
686
+ const state = readPresentString(args, "state");
687
+ if (state) filter.state = state;
688
+ const type = readPresentString(args, "type");
689
+ if (type) filter.type = type;
690
+ const elementId = readPresentString(args, "elementId");
691
+ if (elementId) filter.elementId = elementId;
692
+ const worker = readPresentString(args, "worker");
693
+ if (worker) filter.worker = worker;
694
+ return app.engine.searchJobs(filter);
695
+ },
696
+ },
697
+ {
698
+ name: `${DEBUG_PREFIX}get_process_definition_xml`,
699
+ description:
700
+ "Engine truth: return the deployed BPMN XML of a process definition by key (the routing source of truth, with its FEEL gateway conditions) — reason about WHY an instance routed where it did without a repo checkout. Reads only; null when the key is unknown. An instance carries its processDefinitionKey via search_process_instances, so the path is instance → processDefinitionKey → deployed XML; pair with get_element_instance to locate the parked element.",
701
+ inputSchema: {
702
+ type: "object",
703
+ properties: { processDefinitionKey: OPTIONAL_STRING },
704
+ required: ["processDefinitionKey"],
705
+ },
706
+ run: (args) =>
707
+ app.engine.getProcessDefinitionXml(requireString(args, "processDefinitionKey")),
708
+ },
643
709
  {
644
710
  name: `${DEBUG_PREFIX}cancel_instance`,
645
711
  description: "MUTATING: cancel a running process instance. Requires the shared-secret guard.",
@@ -52,6 +52,9 @@ function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
52
52
  async searchElementInstanceWaitStates() { return []; },
53
53
  async getElementInstance() { return null; },
54
54
  async searchIncidents() { return []; },
55
+ async searchVariables() { return []; },
56
+ async searchJobs() { return []; },
57
+ async getProcessDefinitionXml() { return null; },
55
58
  async resolveIncident() {},
56
59
  async updateJobRetries() {},
57
60
  async setVariables() {},
@@ -38,6 +38,9 @@ function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurf
38
38
  searchElementInstanceWaitStates: async () => [],
39
39
  getElementInstance: async () => null,
40
40
  searchIncidents: async () => [],
41
+ searchVariables: async () => [],
42
+ searchJobs: async () => [],
43
+ getProcessDefinitionXml: async () => null,
41
44
  resolveIncident: async () => {},
42
45
  updateJobRetries: async () => {},
43
46
  setVariables: async () => {},
@@ -61,6 +64,9 @@ const fakeEngine: EngineClient = {
61
64
  searchElementInstanceWaitStates: async () => [],
62
65
  getElementInstance: async () => null,
63
66
  searchIncidents: async () => [],
67
+ searchVariables: async () => [],
68
+ searchJobs: async () => [],
69
+ getProcessDefinitionXml: async () => null,
64
70
  resolveIncident: async () => {},
65
71
  updateJobRetries: async () => {},
66
72
  setVariables: async () => {},
@@ -39,6 +39,9 @@ class MiniEngine implements EngineClient {
39
39
  async searchElementInstanceWaitStates(): Promise<[]> { return []; }
40
40
  async getElementInstance(): Promise<null> { return null; }
41
41
  async searchIncidents(): Promise<never[]> { return []; }
42
+ async searchVariables(): Promise<never[]> { return []; }
43
+ async searchJobs(): Promise<never[]> { return []; }
44
+ async getProcessDefinitionXml(): Promise<null> { return null; }
42
45
  async resolveIncident(): Promise<void> {}
43
46
  async updateJobRetries(): Promise<void> {}
44
47
  async setVariables(): Promise<void> {}
@@ -36,6 +36,9 @@ class MiniEngine implements EngineClient {
36
36
  async searchElementInstanceWaitStates(): Promise<[]> { return []; }
37
37
  async getElementInstance(): Promise<null> { return null; }
38
38
  async searchIncidents(): Promise<never[]> { return []; }
39
+ async searchVariables(): Promise<never[]> { return []; }
40
+ async searchJobs(): Promise<never[]> { return []; }
41
+ async getProcessDefinitionXml(): Promise<null> { return null; }
39
42
  async resolveIncident(): Promise<void> {}
40
43
  async updateJobRetries(): Promise<void> {}
41
44
  async setVariables(): Promise<void> {}
@@ -44,6 +44,15 @@ class NoopEngine implements EngineClient {
44
44
  async searchIncidents() {
45
45
  return [];
46
46
  }
47
+ async searchVariables() {
48
+ return [];
49
+ }
50
+ async searchJobs() {
51
+ return [];
52
+ }
53
+ async getProcessDefinitionXml() {
54
+ return null;
55
+ }
47
56
  async resolveIncident() {}
48
57
  async updateJobRetries() {}
49
58
  async setVariables() {}
@@ -65,6 +65,15 @@ class FakeEngine implements EngineClient {
65
65
  async searchIncidents() {
66
66
  return [];
67
67
  }
68
+ async searchVariables() {
69
+ return [];
70
+ }
71
+ async searchJobs() {
72
+ return [];
73
+ }
74
+ async getProcessDefinitionXml() {
75
+ return null;
76
+ }
68
77
  async resolveIncident() {}
69
78
  async updateJobRetries() {}
70
79
  async setVariables() {}
@@ -97,6 +97,18 @@ function fakeSdkClient(overrides: Partial<NanoSdkClient> = {}): NanoSdkClient &
97
97
  calls.push("searchIncidents");
98
98
  return { items: [] };
99
99
  },
100
+ async searchVariables() {
101
+ calls.push("searchVariables");
102
+ return { items: [] };
103
+ },
104
+ async searchJobs() {
105
+ calls.push("searchJobs");
106
+ return { items: [] };
107
+ },
108
+ async getProcessDefinitionXml() {
109
+ calls.push("getProcessDefinitionXml");
110
+ return "";
111
+ },
100
112
  async resolveIncident(input) {
101
113
  calls.push("resolveIncident");
102
114
  return { ...input };
@@ -24,12 +24,16 @@ import type {
24
24
  IncidentFilter,
25
25
  IncidentState,
26
26
  IncidentSummary,
27
+ JobFilter,
27
28
  JobHandler,
29
+ JobSummary,
28
30
  ProcessInstanceSnapshot,
29
31
  ProcessInstanceState,
30
32
  UserTaskState,
31
33
  UserTaskSummary,
32
34
  UserTaskFilter,
35
+ VariableFilter,
36
+ VariableSummary,
33
37
  WorkerSubscription,
34
38
  } from "../core/host.ts";
35
39
  import { assertDeployedWaitStateType, isBpmnError } from "../core/host.ts";
@@ -360,6 +364,99 @@ export function mapIncidentRow(
360
364
  };
361
365
  }
362
366
 
367
+ /** Map one engine variable row onto a {@link VariableSummary}, or `undefined` when the row is
368
+ * malformed (missing the `variableKey`, owning `processInstanceKey`, or `name` — the identity a
369
+ * caller needs). `value` is the engine's serialized JSON value (a string); a non-string value is
370
+ * JSON-encoded so the field is always a string a caller can `JSON.parse`. `scopeKey` falls back to
371
+ * the process-instance key for a process-level variable that omits it. `isTruncated` is the
372
+ * engine's large-value-clipped flag, defaulting to `false`. The single source of truth
373
+ * `searchVariables` extracts through. */
374
+ export function mapVariableRow(
375
+ row: Record<string, unknown>,
376
+ log: Log,
377
+ ): VariableSummary | undefined {
378
+ const variableKey = presentEngineKey(row.variableKey);
379
+ if (variableKey === undefined) {
380
+ log("warn", "skipping variable with no variableKey in engine response");
381
+ return undefined;
382
+ }
383
+ const processInstanceKey = presentEngineKey(row.processInstanceKey);
384
+ if (processInstanceKey === undefined) {
385
+ log("warn", "skipping variable with no processInstanceKey in engine response", {
386
+ variableKey,
387
+ });
388
+ return undefined;
389
+ }
390
+ const name = presentText(row.name);
391
+ if (name === undefined) {
392
+ log("warn", "skipping variable with no name in engine response", { variableKey });
393
+ return undefined;
394
+ }
395
+ // A process-level variable is scoped to the process instance itself; the engine may omit
396
+ // `scopeKey` for it, so fall back to the process-instance key rather than dropping the row.
397
+ const scopeKey = presentEngineKey(row.scopeKey) ?? processInstanceKey;
398
+ // The engine serializes `value` as a JSON string. Keep a string as-is; encode any other type
399
+ // (an object/array/number a permissive engine might inline) so the field is always a string the
400
+ // caller can `JSON.parse`.
401
+ const value = typeof row.value === "string" ? row.value : JSON.stringify(row.value ?? null);
402
+ return {
403
+ variableKey,
404
+ name,
405
+ value,
406
+ scopeKey,
407
+ processInstanceKey,
408
+ isTruncated: row.isTruncated === true,
409
+ };
410
+ }
411
+
412
+ /** Map one engine job row onto a {@link JobSummary}, or `undefined` when the row is malformed
413
+ * (missing the `jobKey`, `type`, or owning `processInstanceKey`). `worker`/`retries`/`elementId`/
414
+ * `deadline` are best-effort diagnostics: present when the engine reports them, omitted otherwise
415
+ * — an unset `worker` is the "queued, not leased" signal, so it must be absent rather than `""`.
416
+ * `state` is passed through as a bare string (the engine's job-state enum is broad). The single
417
+ * source of truth `searchJobs` extracts through. */
418
+ export function mapJobRow(
419
+ row: Record<string, unknown>,
420
+ log: Log,
421
+ ): JobSummary | undefined {
422
+ const jobKey = presentEngineKey(row.jobKey);
423
+ if (jobKey === undefined) {
424
+ log("warn", "skipping job with no jobKey in engine response");
425
+ return undefined;
426
+ }
427
+ const type = presentText(row.type);
428
+ if (type === undefined) {
429
+ log("warn", "skipping job with no type in engine response", { jobKey });
430
+ return undefined;
431
+ }
432
+ const processInstanceKey = presentEngineKey(row.processInstanceKey);
433
+ if (processInstanceKey === undefined) {
434
+ log("warn", "skipping job with no processInstanceKey in engine response", { jobKey });
435
+ return undefined;
436
+ }
437
+ const state = presentText(row.state);
438
+ if (state === undefined) {
439
+ log("warn", "skipping job with no state in engine response", { jobKey });
440
+ return undefined;
441
+ }
442
+ const worker = presentText(row.worker);
443
+ const retries = typeof row.retries === "number" && Number.isFinite(row.retries)
444
+ ? row.retries
445
+ : undefined;
446
+ const elementId = presentText(row.elementId);
447
+ const deadline = presentText(row.deadline);
448
+ return {
449
+ jobKey,
450
+ type,
451
+ state,
452
+ processInstanceKey,
453
+ ...(worker ? { worker } : {}),
454
+ ...(retries !== undefined ? { retries } : {}),
455
+ ...(elementId ? { elementId } : {}),
456
+ ...(deadline ? { deadline } : {}),
457
+ };
458
+ }
459
+
363
460
  /** A job as delivered to a nano-sdk job handler: the frame fields plus the
364
461
  * acknowledgement actions the handler must call. */
365
462
  export interface NanoSdkActivatedJob {
@@ -463,6 +560,21 @@ export interface NanoSdkClient {
463
560
  consistency?: unknown,
464
561
  options?: unknown,
465
562
  ): Promise<Record<string, unknown>>;
563
+ searchVariables(
564
+ input: { filter?: Record<string, unknown>; page?: Record<string, unknown> },
565
+ consistency?: unknown,
566
+ options?: unknown,
567
+ ): Promise<Record<string, unknown>>;
568
+ searchJobs(
569
+ input: { filter?: Record<string, unknown>; page?: Record<string, unknown> },
570
+ consistency?: unknown,
571
+ options?: unknown,
572
+ ): Promise<Record<string, unknown>>;
573
+ getProcessDefinitionXml(
574
+ input: { processDefinitionKey: string },
575
+ consistency?: unknown,
576
+ options?: unknown,
577
+ ): Promise<unknown>;
466
578
  resolveIncident(
467
579
  input: { incidentKey: string },
468
580
  options?: unknown,
@@ -680,7 +792,12 @@ export class SdkEngineClient implements EngineClient {
680
792
  });
681
793
  return [];
682
794
  }
683
- return [{ processInstanceKey: String(key), state }];
795
+ const processDefinitionKey = presentEngineKey(it.processDefinitionKey);
796
+ return [{
797
+ processInstanceKey: String(key),
798
+ state,
799
+ ...(processDefinitionKey ? { processDefinitionKey } : {}),
800
+ }];
684
801
  });
685
802
  }
686
803
 
@@ -784,6 +901,82 @@ export class SdkEngineClient implements EngineClient {
784
901
  });
785
902
  }
786
903
 
904
+ async searchVariables(filter?: VariableFilter): Promise<VariableSummary[]> {
905
+ // Same shape as `searchIncidents`: build the engine filter from the transport-agnostic
906
+ // selectors, read at zero-wait consistency (a variable search is eventually consistent), and
907
+ // map each row through the single `mapVariableRow` gate, dropping malformed rows.
908
+ const f: Record<string, unknown> = {};
909
+ const processInstanceKey = presentEngineKey(filter?.processInstanceKey);
910
+ if (processInstanceKey) f.processInstanceKey = processInstanceKey;
911
+ const scopeKey = presentEngineKey(filter?.scopeKey);
912
+ if (scopeKey) f.scopeKey = scopeKey;
913
+ const name = filter?.name?.trim();
914
+ if (name) f.name = name;
915
+ const body = await this.client.searchVariables(
916
+ { filter: f },
917
+ { consistency: { waitUpToMs: 0 } },
918
+ );
919
+ const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
920
+ return items.flatMap((it) => {
921
+ const mapped = mapVariableRow(it, this.log);
922
+ return mapped ? [mapped] : [];
923
+ });
924
+ }
925
+
926
+ async searchJobs(filter?: JobFilter): Promise<JobSummary[]> {
927
+ // Same shape as `searchIncidents`: build the engine filter from the transport-agnostic
928
+ // selectors, read at zero-wait consistency (a job search is eventually consistent), and map
929
+ // each row through the single `mapJobRow` gate, dropping malformed rows.
930
+ const f: Record<string, unknown> = {};
931
+ const processInstanceKey = presentEngineKey(filter?.processInstanceKey);
932
+ if (processInstanceKey) f.processInstanceKey = processInstanceKey;
933
+ const state = filter?.state?.trim();
934
+ if (state) f.state = state;
935
+ const type = filter?.type?.trim();
936
+ if (type) f.type = type;
937
+ const elementId = filter?.elementId?.trim();
938
+ if (elementId) f.elementId = elementId;
939
+ const worker = filter?.worker?.trim();
940
+ if (worker) f.worker = worker;
941
+ const body = await this.client.searchJobs(
942
+ { filter: f },
943
+ { consistency: { waitUpToMs: 0 } },
944
+ );
945
+ const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
946
+ return items.flatMap((it) => {
947
+ const mapped = mapJobRow(it, this.log);
948
+ return mapped ? [mapped] : [];
949
+ });
950
+ }
951
+
952
+ async getProcessDefinitionXml(
953
+ processDefinitionKey: string,
954
+ ): Promise<string | null> {
955
+ // A blank key can never address a definition — short-circuit to null rather than issue a
956
+ // `GET /v2/process-definitions//xml` with an empty segment. Normalize a padded-but-valid key so
957
+ // `" 5 "` addresses the same definition, mirroring `getElementInstance`.
958
+ const key = presentEngineKey(processDefinitionKey);
959
+ if (key === undefined) return null;
960
+ let xml: unknown;
961
+ try {
962
+ xml = await this.client.getProcessDefinitionXml(
963
+ { processDefinitionKey: key },
964
+ { consistency: { waitUpToMs: 0 } },
965
+ );
966
+ } catch (err) {
967
+ // A 404 (no such definition) is an expected "not found", not a fault — mirror `getForm`/
968
+ // `getElementInstance`, which treat a failed fetch as absence (null) rather than propagating.
969
+ this.log("warn", "getProcessDefinitionXml: engine fetch failed", {
970
+ processDefinitionKey: key,
971
+ err: err instanceof Error ? err.message : String(err),
972
+ });
973
+ return null;
974
+ }
975
+ // The endpoint returns the raw BPMN XML as a string (204 → empty). Treat a blank/absent body
976
+ // as "no XML" (null) so a caller distinguishes "have it" from "don't".
977
+ return typeof xml === "string" && xml.trim() !== "" ? xml : null;
978
+ }
979
+
787
980
  async resolveIncident(input: { incidentKey: string }): Promise<void> {
788
981
  await this.client.resolveIncident({
789
982
  incidentKey: requireEngineKey(input.incidentKey, "incidentKey"),
@@ -100,13 +100,17 @@ export type {
100
100
  EngineIncidentState,
101
101
  IncidentFilter,
102
102
  IncidentSummary,
103
+ JobFilter,
103
104
  JobHandler,
105
+ JobSummary,
104
106
  ProcessInstanceSnapshot,
105
107
  ProcessInstanceState,
106
108
  SqliteDb,
107
109
  UserTaskState,
108
110
  UserTaskSummary,
109
111
  UserTaskFilter,
112
+ VariableFilter,
113
+ VariableSummary,
110
114
  WatchHandle,
111
115
  WorkerSubscription,
112
116
  } from "./core/host.ts";