@nanobpm/urban 0.49.0 → 0.49.1

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.
@@ -187,6 +187,13 @@ export interface WorkerSubscription {
187
187
  * element-instance state set (which adds SKIPPED/CANCELED/FAILED for tokens).
188
188
  */
189
189
  export type ProcessInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
190
+ /**
191
+ * A user task's lifecycle state, as the engine reports it on a `/v2/user-tasks/search`
192
+ * result. `CREATED` is the only *open* (answerable) state — a task in any other state has
193
+ * already been answered (`COMPLETED`), withdrawn (`CANCELED`), or errored (`FAILED`) and must
194
+ * never surface in the `taskInbox` as if it were actionable.
195
+ */
196
+ export type UserTaskState = "CREATED" | "COMPLETED" | "CANCELED" | "FAILED";
190
197
  /** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
191
198
  export interface ProcessInstanceSnapshot {
192
199
  readonly processInstanceKey: string;
@@ -253,12 +260,16 @@ export interface EngineClient {
253
260
  correlationKey?: string;
254
261
  variables?: Record<string, unknown>;
255
262
  }): Promise<void>;
256
- /** Search open user tasks (optionally by process instance). Each result carries the
257
- * task's resolved form linkage (`formKey`/`externalFormReference`) when present. */
263
+ /** Search user tasks (optionally by process instance, assignee, or candidate group).
264
+ * By default this is unfiltered by lifecycle state and may return tasks in any state
265
+ * (e.g. completed/canceled); pass `state: "CREATED"` to constrain the search to open
266
+ * (answerable) tasks. Each result carries the task's resolved form linkage
267
+ * (`formKey`/`externalFormReference`) when present. */
258
268
  searchUserTasks(filter?: {
259
269
  processInstanceKey?: string;
260
270
  assignee?: string;
261
271
  candidateGroup?: string;
272
+ state?: UserTaskState;
262
273
  }): Promise<UserTaskSummary[]>;
263
274
  /**
264
275
  * Fetch a deployed form's form-js schema for the `taskInbox` surface. Resolve by
@@ -201,7 +201,14 @@ export function mountSurfaces(ctx, app) {
201
201
  source: "surface:taskInbox",
202
202
  handler: async (req) => {
203
203
  const pik = req.query.get("processInstanceKey") ?? undefined;
204
- const tasks = await app.engine.searchUserTasks(pik ? { processInstanceKey: pik } : undefined);
204
+ // Constrain the inbox to open (answerable) tasks. Without a state filter the engine
205
+ // returns tasks in every state (CREATED/COMPLETED/CANCELED/...), so already-answered
206
+ // or withdrawn tasks would surface as if actionable — completing one then fails with
207
+ // "User task ... is not active". See nanobpm/nano-ide#248.
208
+ const tasks = await app.engine.searchUserTasks({
209
+ state: "CREATED",
210
+ ...(pik ? { processInstanceKey: pik } : {}),
211
+ });
205
212
  return json(tasks);
206
213
  },
207
214
  });
@@ -1,4 +1,4 @@
1
- import type { EngineClient, FormSchema, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskSummary, WorkerSubscription } from "../core/host.ts";
1
+ import type { EngineClient, FormSchema, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, 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. */
@@ -140,6 +140,7 @@ export declare class SdkEngineClient implements EngineClient {
140
140
  processInstanceKey?: string;
141
141
  assignee?: string;
142
142
  candidateGroup?: string;
143
+ state?: UserTaskState;
143
144
  }): Promise<UserTaskSummary[]>;
144
145
  getForm(input: {
145
146
  formKey?: string;
@@ -101,7 +101,13 @@ export class SdkEngineClient {
101
101
  async searchUserTasks(filter) {
102
102
  // User tasks are an eventually consistent read; ask for zero-wait consistency so
103
103
  // the search reflects what is currently visible without blocking.
104
- const body = await this.client.searchUserTasks({ filter: { ...(filter ?? {}) } }, { consistency: { waitUpToMs: 0 } });
104
+ const body = await this.client.searchUserTasks(
105
+ // This method is contracted to return *open* tasks; constrain the read to
106
+ // CREATED so answered/canceled tasks never surface as answerable (e.g. a
107
+ // re-escalated instance whose prior escalation task is already COMPLETED).
108
+ // Spread caller filters first and pin `state` last so a caller can never
109
+ // override the open-only (CREATED) constraint this method contracts to.
110
+ { filter: { ...(filter ?? {}), state: "CREATED" } }, { consistency: { waitUpToMs: 0 } });
105
111
  const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
106
112
  return items.flatMap((it) => {
107
113
  const userTaskKey = it.userTaskKey ?? it.key;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.49.0",
3
+ "version": "0.49.1",
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",
@@ -217,6 +217,14 @@ export interface WorkerSubscription {
217
217
  */
218
218
  export type ProcessInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
219
219
 
220
+ /**
221
+ * A user task's lifecycle state, as the engine reports it on a `/v2/user-tasks/search`
222
+ * result. `CREATED` is the only *open* (answerable) state — a task in any other state has
223
+ * already been answered (`COMPLETED`), withdrawn (`CANCELED`), or errored (`FAILED`) and must
224
+ * never surface in the `taskInbox` as if it were actionable.
225
+ */
226
+ export type UserTaskState = "CREATED" | "COMPLETED" | "CANCELED" | "FAILED";
227
+
220
228
  /** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
221
229
  export interface ProcessInstanceSnapshot {
222
230
  readonly processInstanceKey: string;
@@ -277,12 +285,16 @@ export interface EngineClient {
277
285
  correlationKey?: string;
278
286
  variables?: Record<string, unknown>;
279
287
  }): Promise<void>;
280
- /** Search open user tasks (optionally by process instance). Each result carries the
281
- * task's resolved form linkage (`formKey`/`externalFormReference`) when present. */
288
+ /** Search user tasks (optionally by process instance, assignee, or candidate group).
289
+ * By default this is unfiltered by lifecycle state and may return tasks in any state
290
+ * (e.g. completed/canceled); pass `state: "CREATED"` to constrain the search to open
291
+ * (answerable) tasks. Each result carries the task's resolved form linkage
292
+ * (`formKey`/`externalFormReference`) when present. */
282
293
  searchUserTasks(filter?: {
283
294
  processInstanceKey?: string;
284
295
  assignee?: string;
285
296
  candidateGroup?: string;
297
+ state?: UserTaskState;
286
298
  }): Promise<UserTaskSummary[]>;
287
299
  /**
288
300
  * Fetch a deployed form's form-js schema for the `taskInbox` surface. Resolve by
@@ -171,12 +171,28 @@ test("/api/tasks surfaces the resolved form linkage", async () => {
171
171
  };
172
172
  const { tasks } = inboxRoutes(engine);
173
173
  const res = await call(tasks, { query: "processInstanceKey=pi-9" });
174
- assert.deepEqual(seenFilter, { processInstanceKey: "pi-9" });
174
+ assert.deepEqual(seenFilter, { state: "CREATED", processInstanceKey: "pi-9" });
175
175
  const parsed = JSON.parse(String(res.body));
176
176
  assert.equal(parsed[0].formKey, "form-123");
177
177
  assert.equal(parsed[1].formKey, undefined);
178
178
  });
179
179
 
180
+ test("/api/tasks constrains the search to open (CREATED) tasks", async () => {
181
+ let seenFilter: unknown;
182
+ const engine: EngineClient = {
183
+ ...fakeEngine,
184
+ searchUserTasks: async (filter) => {
185
+ seenFilter = filter;
186
+ return [];
187
+ },
188
+ };
189
+ const { tasks } = inboxRoutes(engine);
190
+ await call(tasks);
191
+ // No processInstanceKey supplied, but the inbox must still pin state=CREATED so that
192
+ // already-answered/canceled tasks never surface as answerable (nanobpm/nano-ide#248).
193
+ assert.deepEqual(seenFilter, { state: "CREATED" });
194
+ });
195
+
180
196
  test("/api/form resolves a linked form's schema", async () => {
181
197
  let seen: unknown;
182
198
  const schema = { type: "default", schemaVersion: 18, components: [{ type: "textfield", key: "name", label: "Name" }] };
@@ -215,7 +215,14 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
215
215
  source: "surface:taskInbox",
216
216
  handler: async (req) => {
217
217
  const pik = req.query.get("processInstanceKey") ?? undefined;
218
- const tasks = await app.engine.searchUserTasks(pik ? { processInstanceKey: pik } : undefined);
218
+ // Constrain the inbox to open (answerable) tasks. Without a state filter the engine
219
+ // returns tasks in every state (CREATED/COMPLETED/CANCELED/...), so already-answered
220
+ // or withdrawn tasks would surface as if actionable — completing one then fails with
221
+ // "User task ... is not active". See nanobpm/nano-ide#248.
222
+ const tasks = await app.engine.searchUserTasks({
223
+ state: "CREATED",
224
+ ...(pik ? { processInstanceKey: pik } : {}),
225
+ });
219
226
  return json(tasks);
220
227
  },
221
228
  });
@@ -177,8 +177,10 @@ test("publishMessage defaults correlationKey/variables", async () => {
177
177
 
178
178
  test("searchUserTasks passes zero-wait consistency and maps items", async () => {
179
179
  let consistency: unknown;
180
+ let input: { filter?: Record<string, unknown> } | undefined;
180
181
  const client = fakeSdkClient({
181
- searchUserTasks: async (_input, c) => {
182
+ searchUserTasks: async (i, c) => {
183
+ input = i;
182
184
  consistency = c;
183
185
  return {
184
186
  items: [
@@ -192,6 +194,11 @@ test("searchUserTasks passes zero-wait consistency and maps items", async () =>
192
194
  const engine = new SdkEngineClient(client);
193
195
  const tasks = await engine.searchUserTasks({ processInstanceKey: "pi" });
194
196
  assert.deepEqual(consistency, { consistency: { waitUpToMs: 0 } });
197
+ // The search is constrained to open (CREATED) tasks so terminal tasks never
198
+ // surface as answerable, while caller filters (processInstanceKey) still apply.
199
+ // Assert individual fields so the check is independent of key insertion order.
200
+ assert.equal(input?.filter?.state, "CREATED");
201
+ assert.equal(input?.filter?.processInstanceKey, "pi");
195
202
  assert.deepEqual(tasks, [
196
203
  { userTaskKey: "7", elementId: "task_a", variables: { x: 1 } },
197
204
  { userTaskKey: "8", elementId: undefined, variables: undefined },
@@ -18,6 +18,7 @@ import type {
18
18
  JobHandler,
19
19
  ProcessInstanceSnapshot,
20
20
  ProcessInstanceState,
21
+ UserTaskState,
21
22
  UserTaskSummary,
22
23
  WorkerSubscription,
23
24
  } from "../core/host.ts";
@@ -221,11 +222,17 @@ export class SdkEngineClient implements EngineClient {
221
222
  processInstanceKey?: string;
222
223
  assignee?: string;
223
224
  candidateGroup?: string;
225
+ state?: UserTaskState;
224
226
  }): Promise<UserTaskSummary[]> {
225
227
  // User tasks are an eventually consistent read; ask for zero-wait consistency so
226
228
  // the search reflects what is currently visible without blocking.
227
229
  const body = await this.client.searchUserTasks(
228
- { filter: { ...(filter ?? {}) } },
230
+ // This method is contracted to return *open* tasks; constrain the read to
231
+ // CREATED so answered/canceled tasks never surface as answerable (e.g. a
232
+ // re-escalated instance whose prior escalation task is already COMPLETED).
233
+ // Spread caller filters first and pin `state` last so a caller can never
234
+ // override the open-only (CREATED) constraint this method contracts to.
235
+ { filter: { ...(filter ?? {}), state: "CREATED" } },
229
236
  { consistency: { waitUpToMs: 0 } },
230
237
  );
231
238
  const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];