@nanobpm/workflow 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -105,6 +105,19 @@ const { processInstanceKey } = await client.start(onboarding, {});
105
105
  await client.signal(onboarding, "approved", userId, { by: "alice" });
106
106
  ```
107
107
 
108
+ A long-running step (transcription, an agent call, a multi-GB upload) can
109
+ outlive the engine's default job lock: the lease expires mid-handler, the
110
+ engine re-activates the job, and a second worker runs it again. Declare the
111
+ lock timeout per step with `w.run(name, handler, { timeoutMs })` (overrides the
112
+ `Worker`'s worker-wide `jobTimeoutMs`, default 30s, for that step's job type):
113
+
114
+ ```ts
115
+ const flow = defineFlow("uploads", (w) => {
116
+ w.run("transcode", transcode, { timeoutMs: 3_600_000 }); // 1h lock for this step
117
+ w.run("notify", notify);
118
+ });
119
+ ```
120
+
108
121
  A human-in-the-loop **approval gate** with a form, an assignment, and an output
109
122
  mapping that stamps the decision back onto a process variable:
110
123
 
@@ -45,6 +45,12 @@ export interface FlowBuilder<C extends object = object> {
45
45
  */
46
46
  run<K extends string>(name: K, handler: TypedHandler<VarsOf<C, K>, ResultOf<C, K>>, opts?: {
47
47
  io?: HumanIoMapping;
48
+ /** Job lock timeout, ms, for this step's derived worker (issue #596): how long the
49
+ * engine leases an activated job before re-activating it elsewhere. Set it above the
50
+ * handler's worst-case runtime for long steps (transcription, an agent call, a
51
+ * multi-GB upload) so the lock cannot expire mid-handler and the job run twice.
52
+ * Overrides the `Worker`'s worker-wide `jobTimeoutMs` for this step's job type. */
53
+ timeoutMs?: number;
48
54
  }): FlowBuilder<C>;
49
55
  /**
50
56
  * A durable activity served by a worker OUTSIDE this program (a BPMN service
@@ -239,6 +245,13 @@ export declare function walkNodes(nodes: FlowNode[], visit: (n: FlowNode) => voi
239
245
  * { jobType })`. Deduplicated (preserving first-seen order) since several steps
240
246
  * may intentionally share one override token. */
241
247
  export declare function externalJobTypes(flow: DeclarativeFlow): string[];
248
+ /** The job types of a flow's inline-handled `run` steps (anywhere in the tree) —
249
+ * exactly the set the in-process `Worker` hosts (same walk, same derived
250
+ * `<flowId>:<stepName>`; a `run` step has no `jobType` override). Codegen
251
+ * consumes this to treat those steps as already-served: a worker-stub
252
+ * scaffolder must never plan a stub for them (nano-ide#595). Deduplicated,
253
+ * preserving first-seen order. */
254
+ export declare function inlineJobTypes(flow: DeclarativeFlow): string[];
242
255
  /** A renderable BPMN element the emitter has placed. A kind's `emit` handler
243
256
  * adds one (or more) via `api.addNode(...)` (or a typed helper like
244
257
  * `api.addServiceTask`). */
@@ -319,6 +319,26 @@ export function externalJobTypes(flow) {
319
319
  });
320
320
  return types;
321
321
  }
322
+ /** The job types of a flow's inline-handled `run` steps (anywhere in the tree) —
323
+ * exactly the set the in-process `Worker` hosts (same walk, same derived
324
+ * `<flowId>:<stepName>`; a `run` step has no `jobType` override). Codegen
325
+ * consumes this to treat those steps as already-served: a worker-stub
326
+ * scaffolder must never plan a stub for them (nano-ide#595). Deduplicated,
327
+ * preserving first-seen order. */
328
+ export function inlineJobTypes(flow) {
329
+ const seen = new Set();
330
+ const types = [];
331
+ walkNodes(flow.steps, (n) => {
332
+ if (n.kind !== "run")
333
+ return;
334
+ const type = jobType(flow.id, n.name);
335
+ if (seen.has(type))
336
+ return;
337
+ seen.add(type);
338
+ types.push(type);
339
+ });
340
+ return types;
341
+ }
322
342
  function requireTimerAt(node) {
323
343
  if (node.at === undefined)
324
344
  throw new Error(`timer event is missing its { at } value`);
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { defineWorkflow, imperativeToBpmn, replayOnce } from "./imperative.js";
2
2
  export type { Journal, ReplayStep } from "./imperative.js";
3
- export { defineFlow, declarativeToBpmn, externalJobTypes, walkNodes } from "./declarative.js";
3
+ export { defineFlow, declarativeToBpmn, externalJobTypes, inlineJobTypes, walkNodes } from "./declarative.js";
4
4
  export type { FlowBuilder } from "./declarative.js";
5
5
  export type { BoundaryOptions } from "./nodes/boundary.js";
6
6
  export type { HumanOptions, HumanIoMapping, HumanIoEntry } from "./nodes/human.js";
package/dist/index.js CHANGED
@@ -39,7 +39,7 @@
39
39
  // code-first surface (steps are not engine-visible; requires determinism
40
40
  // discipline). Retained for advanced durable-orchestration use only.
41
41
  export { defineWorkflow, imperativeToBpmn, replayOnce } from "./imperative.js";
42
- export { defineFlow, declarativeToBpmn, externalJobTypes, walkNodes } from "./declarative.js";
42
+ export { defineFlow, declarativeToBpmn, externalJobTypes, inlineJobTypes, walkNodes } from "./declarative.js";
43
43
  export { layoutBpmn, declarativeToLayoutedBpmn } from "./layout.js";
44
44
  export { envelope } from "./envelope.js";
45
45
  export { WorkflowClient, WorkflowError, toBpmn, toDeployableBpmn } from "./client.js";
@@ -4,6 +4,7 @@
4
4
  // wrapped in an embedded MI sub-process. Contributed through the extension seam.
5
5
  import { assertIdent, escapeXml } from "../xml.js";
6
6
  import { registerNodeKind } from "./registry.js";
7
+ import { hasTaskExtensions } from "./task.js";
7
8
  /** The `<bpmn:multiInstanceLoopCharacteristics>` block for a `forEach` node — the
8
9
  * same shape whether lifted onto a leaf service task or a sub-process. */
9
10
  function miCharacteristics(node) {
@@ -82,13 +83,14 @@ registerNodeKind("forEach", {
82
83
  const only = node.body.length === 1 ? node.body[0] : undefined;
83
84
  // A single service-task body carries the MI characteristics directly; a
84
85
  // multi-step body is wrapped in an embedded MI sub-process (its own token
85
- // scope, with its own start/end). A prompt-carrying `task` is deliberately
86
+ // scope, with its own start/end). A `task` with an extension delta (agent
87
+ // marker, prompt binding or io mapping — see `hasTaskExtensions`) is deliberately
86
88
  // EXCLUDED from this fast path: computing its `zeebe:linkedResources`/
87
89
  // `appendPrompt` delta lives in the `task` kind's own emitter, so lifting it
88
90
  // here would silently drop that binding. It falls through to the sub-process
89
91
  // path, where the `task` emitter renders the agent service task (prompt
90
92
  // intact) inside the MI scope.
91
- if (only && (only.kind === "run" || (only.kind === "task" && only.prompt === undefined))) {
93
+ if (only && (only.kind === "run" || (only.kind === "task" && !hasTaskExtensions(only)))) {
92
94
  api.addServiceTask(only, { mi });
93
95
  api.connect(incoming, only.name);
94
96
  return [api.newEdge(only.name)];
@@ -6,6 +6,7 @@ declare module "../types.js" {
6
6
  name: string;
7
7
  envelopes?: NodeEnvelopes;
8
8
  io?: HumanIoMapping;
9
+ timeoutMs?: number;
9
10
  };
10
11
  }
11
12
  }
package/dist/nodes/run.js CHANGED
@@ -11,8 +11,18 @@ registerNodeKind("run", {
11
11
  if (typeof handler !== "function")
12
12
  throw new Error(`run("${name}") needs a handler function`);
13
13
  assertIoMapping(`run("${name}")`, opts?.io);
14
+ const timeoutMs = opts?.timeoutMs;
15
+ if (timeoutMs !== undefined && (!Number.isInteger(timeoutMs) || timeoutMs <= 0)) {
16
+ throw new Error(`run("${name}"): timeoutMs must be a positive integer (ms)`);
17
+ }
14
18
  api.handlers[name] = handler;
15
- api.out.push({ kind: "run", name, envelopes: api.contractEnvelopes(name), io: opts?.io });
19
+ api.out.push({
20
+ kind: "run",
21
+ name,
22
+ envelopes: api.contractEnvelopes(name),
23
+ io: opts?.io,
24
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
25
+ });
16
26
  return api.self();
17
27
  },
18
28
  emit: (node, incoming, _loop, api) => {
@@ -27,6 +27,11 @@ declare module "../types.js" {
27
27
  jobType?: string;
28
28
  /** The agent prompt binding, when the task is an LLM-agent service task. */
29
29
  prompt?: PromptBinding;
30
+ /** Whether the task is an AGENT service task, emitting the engine-native
31
+ * AgentTask marker `<zeebe:agentDefinition agentType="external" />`
32
+ * (issue #597). Resolved at authoring time: an explicit `agent` option
33
+ * wins, otherwise it is `true` exactly when a `prompt` is bound. */
34
+ agent?: boolean;
30
35
  /** An explicit `<zeebe:ioMapping>` on the service task — arbitrary input
31
36
  * (and output) variable mappings, using the shared ioMapping shape. When a
32
37
  * `prompt.append` is ALSO present it is folded into this single mapping as
@@ -49,7 +54,29 @@ declare module "../declarative.js" {
49
54
  task<K extends string>(name: K, opts: {
50
55
  jobType?: string;
51
56
  prompt: PromptBinding;
57
+ agent?: boolean;
58
+ io?: HumanIoMapping;
59
+ }): FlowBuilder<C>;
60
+ /**
61
+ * A PROMPT-LESS agent external service task: as `task`, but `agent: true`
62
+ * emits the AgentTask marker `<zeebe:agentDefinition agentType="external" />`
63
+ * (the nano-workforce agent-task signal, issue #597) without binding a
64
+ * prompt resource — the worker is steered by its inputs alone.
65
+ */
66
+ task<K extends string>(name: K, opts: {
67
+ jobType?: string;
68
+ agent: boolean;
52
69
  io?: HumanIoMapping;
53
70
  }): FlowBuilder<C>;
54
71
  }
55
72
  }
73
+ /** Whether a `task` node carries an extension delta (agent marker, prompt
74
+ * binding, or io mapping) that only this module's emitter renders. The SINGLE
75
+ * predicate any other emitter (e.g. `forEach`'s single-task fast path) must
76
+ * consult before rendering the task shell itself, so a new delta can never be
77
+ * silently dropped by a bypass. */
78
+ export declare function hasTaskExtensions(node: {
79
+ agent?: boolean;
80
+ prompt?: PromptBinding;
81
+ io?: HumanIoMapping;
82
+ }): boolean;
@@ -57,7 +57,10 @@ function resolvePrompt(name, prompt) {
57
57
  * `<zeebe:ioMapping>` (never two). Element order matches the golden:
58
58
  * `linkedResources` (when any) then `ioMapping` (when any), the latter after
59
59
  * `taskDefinition`/`properties`. */
60
- function taskExtensions(prompt, io) {
60
+ function taskExtensions(agent, prompt, io) {
61
+ // The AgentTask marker leads the delta so it sits directly after the
62
+ // taskDefinition, matching the nano-workforce reference shape.
63
+ const marker = agent === true ? ` <zeebe:agentDefinition agentType="external" />\n` : "";
61
64
  const linked = prompt !== undefined
62
65
  ? ` <zeebe:linkedResources>\n` +
63
66
  ` <zeebe:linkedResource resourceId="${escapeXml(prompt.resourceId)}" ` +
@@ -73,7 +76,15 @@ function taskExtensions(prompt, io) {
73
76
  if (prompt?.append !== undefined)
74
77
  inputs.push({ source: prompt.append, target: "appendPrompt" });
75
78
  const merged = { input: inputs, output: io?.output ?? [] };
76
- return linked + renderIoMapping(merged);
79
+ return marker + linked + renderIoMapping(merged);
80
+ }
81
+ /** Whether a `task` node carries an extension delta (agent marker, prompt
82
+ * binding, or io mapping) that only this module's emitter renders. The SINGLE
83
+ * predicate any other emitter (e.g. `forEach`'s single-task fast path) must
84
+ * consult before rendering the task shell itself, so a new delta can never be
85
+ * silently dropped by a bypass. */
86
+ export function hasTaskExtensions(node) {
87
+ return node.agent === true || node.prompt !== undefined || node.io !== undefined;
77
88
  }
78
89
  registerNodeKind("task", {
79
90
  build: (api) => (name, opts) => {
@@ -83,12 +94,17 @@ registerNodeKind("task", {
83
94
  assertJobType("task jobType", override);
84
95
  const prompt = opts?.prompt !== undefined ? resolvePrompt(name, opts.prompt) : undefined;
85
96
  assertIoMapping(`task("${name}")`, opts?.io);
97
+ if (opts?.agent !== undefined && typeof opts.agent !== "boolean") {
98
+ throw new Error(`task("${name}") agent must be a boolean when provided`);
99
+ }
100
+ const agent = opts?.agent ?? prompt !== undefined;
86
101
  api.out.push({
87
102
  kind: "task",
88
103
  name,
89
104
  envelopes: api.contractEnvelopes(name),
90
105
  jobType: override,
91
106
  prompt,
107
+ agent,
92
108
  io: opts?.io,
93
109
  });
94
110
  return api.self();
@@ -97,7 +113,7 @@ registerNodeKind("task", {
97
113
  // Neither a prompt binding nor an explicit io mapping → the standard
98
114
  // service-task emission is preserved unchanged (no `linkedResources`, no
99
115
  // `ioMapping`).
100
- if (node.prompt === undefined && node.io === undefined) {
116
+ if (!hasTaskExtensions(node)) {
101
117
  api.addServiceTask(node);
102
118
  api.connect(incoming, node.name);
103
119
  return [api.newEdge(node.name)];
@@ -107,7 +123,7 @@ registerNodeKind("task", {
107
123
  // properties, recording envelopes); we only supply the `linkedResource`
108
124
  // and/or `ioMapping` delta, so the envelope/taskDefinition serialization has
109
125
  // a single source.
110
- api.addServiceTask(node, { extraExt: taskExtensions(node.prompt, node.io) });
126
+ api.addServiceTask(node, { extraExt: taskExtensions(node.agent, node.prompt, node.io) });
111
127
  api.connect(incoming, node.name);
112
128
  return [api.newEdge(node.name)];
113
129
  },
package/dist/worker.d.ts CHANGED
@@ -54,6 +54,9 @@ export declare class Worker {
54
54
  private readonly onError?;
55
55
  /** job type → { workflowId, handle } */
56
56
  private readonly routes;
57
+ /** Job lock timeout (ms) per derived job type, from a `run` step's `{ timeoutMs }`
58
+ * (issue #596). Overrides the worker-wide `jobTimeoutMs` for that type. */
59
+ private readonly typeTimeoutMs;
57
60
  private running;
58
61
  private workers;
59
62
  constructor(opts: WorkerOptions);
package/dist/worker.js CHANGED
@@ -30,6 +30,9 @@ export class Worker {
30
30
  onError;
31
31
  /** job type → { workflowId, handle } */
32
32
  routes = new Map();
33
+ /** Job lock timeout (ms) per derived job type, from a `run` step's `{ timeoutMs }`
34
+ * (issue #596). Overrides the worker-wide `jobTimeoutMs` for that type. */
35
+ typeTimeoutMs = new Map();
33
36
  running = false;
34
37
  workers = [];
35
38
  constructor(opts) {
@@ -87,6 +90,10 @@ export class Worker {
87
90
  return { variables };
88
91
  },
89
92
  });
93
+ // A step-declared job lock timeout (issue #596) applies to this step's derived
94
+ // job type, overriding the worker-wide `jobTimeoutMs` at `start()`.
95
+ if (s.timeoutMs !== undefined)
96
+ this.typeTimeoutMs.set(jobType(wf.id, s.name), s.timeoutMs);
90
97
  });
91
98
  }
92
99
  }
@@ -141,7 +148,8 @@ export class Worker {
141
148
  jobType: type,
142
149
  workerName: this.name,
143
150
  maxParallelJobs: this.maxParallelJobs,
144
- jobTimeoutMs: this.jobTimeoutMs,
151
+ // A step-declared `{ timeoutMs }` (issue #596) wins over the worker-wide default.
152
+ jobTimeoutMs: this.typeTimeoutMs.get(type) ?? this.jobTimeoutMs,
145
153
  pollTimeoutMs: this.pollTimeoutMs,
146
154
  autoStart: false,
147
155
  jobHandler: (job) => this.handleJob(type, route, job),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/workflow",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Code-first durable orchestration for nanobpmn (ADR 0044/0045). Author a durable workflow as a declarative flow — `defineFlow(id, w => { w.run(...); w.signal(...); w.task(...) })` — and the SDK derives the executable BPMN model, the job types, and the message/correlation wiring, then hosts a generic worker. `w.run` is a local step, `w.signal` is a durable human-in-the-loop wait, `w.task` is a step serviced by an external worker. `defineFlow` is the single code-first surface; the imperative `defineWorkflow` replay function is experimental/internal, not a recommended API. No diagram, no task-type wiring, no correlation plumbing authored by hand — and you can optionally auto-generate a diagram (DI) for the derived model with `declarativeToLayoutedBpmn` via the optional `bpmn-auto-layout` peer dependency. Talks to a running nanobpmn gateway through @nanobpm/nano-sdk (the shared engine-transport spine — ADR 0055), which negotiates the Falcon protocol on a Nano server and REST elsewhere.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -102,7 +102,15 @@ export interface FlowBuilder<C extends object = object> {
102
102
  run<K extends string>(
103
103
  name: K,
104
104
  handler: TypedHandler<VarsOf<C, K>, ResultOf<C, K>>,
105
- opts?: { io?: HumanIoMapping },
105
+ opts?: {
106
+ io?: HumanIoMapping;
107
+ /** Job lock timeout, ms, for this step's derived worker (issue #596): how long the
108
+ * engine leases an activated job before re-activating it elsewhere. Set it above the
109
+ * handler's worst-case runtime for long steps (transcription, an agent call, a
110
+ * multi-GB upload) so the lock cannot expire mid-handler and the job run twice.
111
+ * Overrides the `Worker`'s worker-wide `jobTimeoutMs` for this step's job type. */
112
+ timeoutMs?: number;
113
+ },
106
114
  ): FlowBuilder<C>;
107
115
  /**
108
116
  * A durable activity served by a worker OUTSIDE this program (a BPMN service
@@ -562,6 +570,25 @@ export function externalJobTypes(flow: DeclarativeFlow): string[] {
562
570
  return types;
563
571
  }
564
572
 
573
+ /** The job types of a flow's inline-handled `run` steps (anywhere in the tree) —
574
+ * exactly the set the in-process `Worker` hosts (same walk, same derived
575
+ * `<flowId>:<stepName>`; a `run` step has no `jobType` override). Codegen
576
+ * consumes this to treat those steps as already-served: a worker-stub
577
+ * scaffolder must never plan a stub for them (nano-ide#595). Deduplicated,
578
+ * preserving first-seen order. */
579
+ export function inlineJobTypes(flow: DeclarativeFlow): string[] {
580
+ const seen = new Set<string>();
581
+ const types: string[] = [];
582
+ walkNodes(flow.steps, (n) => {
583
+ if (n.kind !== "run") return;
584
+ const type = jobType(flow.id, n.name);
585
+ if (seen.has(type)) return;
586
+ seen.add(type);
587
+ types.push(type);
588
+ });
589
+ return types;
590
+ }
591
+
565
592
  function requireTimerAt(node: { at?: string }): string {
566
593
  if (node.at === undefined) throw new Error(`timer event is missing its { at } value`);
567
594
  return node.at;
package/src/index.ts CHANGED
@@ -41,7 +41,7 @@
41
41
  // discipline). Retained for advanced durable-orchestration use only.
42
42
  export { defineWorkflow, imperativeToBpmn, replayOnce } from "./imperative.js";
43
43
  export type { Journal, ReplayStep } from "./imperative.js";
44
- export { defineFlow, declarativeToBpmn, externalJobTypes, walkNodes } from "./declarative.js";
44
+ export { defineFlow, declarativeToBpmn, externalJobTypes, inlineJobTypes, walkNodes } from "./declarative.js";
45
45
  export type { FlowBuilder } from "./declarative.js";
46
46
  // The `.boundary(...)` option type (its builder method augments FlowBuilder from
47
47
  // the boundary kind module — S2/#317).
@@ -7,6 +7,7 @@ import type { FlowNode } from "../types.js";
7
7
  import type { Block } from "../declarative.js";
8
8
  import { assertIdent, escapeXml } from "../xml.js";
9
9
  import { registerNodeKind } from "./registry.js";
10
+ import { hasTaskExtensions } from "./task.js";
10
11
 
11
12
  /** The `forEach` node shape (its `FlowNode` variant). */
12
13
  interface ForEachNode {
@@ -115,13 +116,14 @@ registerNodeKind("forEach", {
115
116
  const only = node.body.length === 1 ? node.body[0] : undefined;
116
117
  // A single service-task body carries the MI characteristics directly; a
117
118
  // multi-step body is wrapped in an embedded MI sub-process (its own token
118
- // scope, with its own start/end). A prompt-carrying `task` is deliberately
119
+ // scope, with its own start/end). A `task` with an extension delta (agent
120
+ // marker, prompt binding or io mapping — see `hasTaskExtensions`) is deliberately
119
121
  // EXCLUDED from this fast path: computing its `zeebe:linkedResources`/
120
122
  // `appendPrompt` delta lives in the `task` kind's own emitter, so lifting it
121
123
  // here would silently drop that binding. It falls through to the sub-process
122
124
  // path, where the `task` emitter renders the agent service task (prompt
123
125
  // intact) inside the MI scope.
124
- if (only && (only.kind === "run" || (only.kind === "task" && only.prompt === undefined))) {
126
+ if (only && (only.kind === "run" || (only.kind === "task" && !hasTaskExtensions(only)))) {
125
127
  api.addServiceTask(only, { mi });
126
128
  api.connect(incoming, only.name);
127
129
  return [api.newEdge(only.name)];
package/src/nodes/run.ts CHANGED
@@ -11,17 +11,27 @@ import { registerNodeKind } from "./registry.js";
11
11
 
12
12
  declare module "../types.js" {
13
13
  interface FlowNodeRegistry {
14
- run: { kind: "run"; name: string; envelopes?: NodeEnvelopes; io?: HumanIoMapping };
14
+ run: { kind: "run"; name: string; envelopes?: NodeEnvelopes; io?: HumanIoMapping; timeoutMs?: number };
15
15
  }
16
16
  }
17
17
 
18
18
  registerNodeKind("run", {
19
- build: (api) => (name: string, handler: StepHandler, opts?: { io?: HumanIoMapping }) => {
19
+ build: (api) => (name: string, handler: StepHandler, opts?: { io?: HumanIoMapping; timeoutMs?: number }) => {
20
20
  api.claim(name);
21
21
  if (typeof handler !== "function") throw new Error(`run("${name}") needs a handler function`);
22
22
  assertIoMapping(`run("${name}")`, opts?.io);
23
+ const timeoutMs = opts?.timeoutMs;
24
+ if (timeoutMs !== undefined && (!Number.isInteger(timeoutMs) || timeoutMs <= 0)) {
25
+ throw new Error(`run("${name}"): timeoutMs must be a positive integer (ms)`);
26
+ }
23
27
  api.handlers[name] = handler;
24
- api.out.push({ kind: "run", name, envelopes: api.contractEnvelopes(name), io: opts?.io });
28
+ api.out.push({
29
+ kind: "run",
30
+ name,
31
+ envelopes: api.contractEnvelopes(name),
32
+ io: opts?.io,
33
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
34
+ });
25
35
  return api.self();
26
36
  },
27
37
  emit: (node, incoming, _loop, api) => {
package/src/nodes/task.ts CHANGED
@@ -46,6 +46,11 @@ declare module "../types.js" {
46
46
  jobType?: string;
47
47
  /** The agent prompt binding, when the task is an LLM-agent service task. */
48
48
  prompt?: PromptBinding;
49
+ /** Whether the task is an AGENT service task, emitting the engine-native
50
+ * AgentTask marker `<zeebe:agentDefinition agentType="external" />`
51
+ * (issue #597). Resolved at authoring time: an explicit `agent` option
52
+ * wins, otherwise it is `true` exactly when a `prompt` is bound. */
53
+ agent?: boolean;
49
54
  /** An explicit `<zeebe:ioMapping>` on the service task — arbitrary input
50
55
  * (and output) variable mappings, using the shared ioMapping shape. When a
51
56
  * `prompt.append` is ALSO present it is folded into this single mapping as
@@ -68,7 +73,17 @@ declare module "../declarative.js" {
68
73
  */
69
74
  task<K extends string>(
70
75
  name: K,
71
- opts: { jobType?: string; prompt: PromptBinding; io?: HumanIoMapping },
76
+ opts: { jobType?: string; prompt: PromptBinding; agent?: boolean; io?: HumanIoMapping },
77
+ ): FlowBuilder<C>;
78
+ /**
79
+ * A PROMPT-LESS agent external service task: as `task`, but `agent: true`
80
+ * emits the AgentTask marker `<zeebe:agentDefinition agentType="external" />`
81
+ * (the nano-workforce agent-task signal, issue #597) without binding a
82
+ * prompt resource — the worker is steered by its inputs alone.
83
+ */
84
+ task<K extends string>(
85
+ name: K,
86
+ opts: { jobType?: string; agent: boolean; io?: HumanIoMapping },
72
87
  ): FlowBuilder<C>;
73
88
  }
74
89
  }
@@ -118,7 +133,14 @@ function resolvePrompt(name: string, prompt: PromptBinding): PromptBinding {
118
133
  * `<zeebe:ioMapping>` (never two). Element order matches the golden:
119
134
  * `linkedResources` (when any) then `ioMapping` (when any), the latter after
120
135
  * `taskDefinition`/`properties`. */
121
- function taskExtensions(prompt: PromptBinding | undefined, io: HumanIoMapping | undefined): string {
136
+ function taskExtensions(
137
+ agent: boolean | undefined,
138
+ prompt: PromptBinding | undefined,
139
+ io: HumanIoMapping | undefined,
140
+ ): string {
141
+ // The AgentTask marker leads the delta so it sits directly after the
142
+ // taskDefinition, matching the nano-workforce reference shape.
143
+ const marker = agent === true ? ` <zeebe:agentDefinition agentType="external" />\n` : "";
122
144
  const linked =
123
145
  prompt !== undefined
124
146
  ? ` <zeebe:linkedResources>\n` +
@@ -134,22 +156,36 @@ function taskExtensions(prompt: PromptBinding | undefined, io: HumanIoMapping |
134
156
  const inputs: HumanIoEntry[] = [...(io?.input ?? [])];
135
157
  if (prompt?.append !== undefined) inputs.push({ source: prompt.append, target: "appendPrompt" });
136
158
  const merged: HumanIoMapping = { input: inputs, output: io?.output ?? [] };
137
- return linked + renderIoMapping(merged);
159
+ return marker + linked + renderIoMapping(merged);
160
+ }
161
+
162
+ /** Whether a `task` node carries an extension delta (agent marker, prompt
163
+ * binding, or io mapping) that only this module's emitter renders. The SINGLE
164
+ * predicate any other emitter (e.g. `forEach`'s single-task fast path) must
165
+ * consult before rendering the task shell itself, so a new delta can never be
166
+ * silently dropped by a bypass. */
167
+ export function hasTaskExtensions(node: { agent?: boolean; prompt?: PromptBinding; io?: HumanIoMapping }): boolean {
168
+ return node.agent === true || node.prompt !== undefined || node.io !== undefined;
138
169
  }
139
170
 
140
171
  registerNodeKind("task", {
141
- build: (api) => (name: string, opts?: { jobType?: string; prompt?: PromptBinding; io?: HumanIoMapping }) => {
172
+ build: (api) => (name: string, opts?: { jobType?: string; prompt?: PromptBinding; agent?: boolean; io?: HumanIoMapping }) => {
142
173
  api.claim(name);
143
174
  const override = opts?.jobType;
144
175
  if (override !== undefined) assertJobType("task jobType", override);
145
176
  const prompt = opts?.prompt !== undefined ? resolvePrompt(name, opts.prompt) : undefined;
146
177
  assertIoMapping(`task("${name}")`, opts?.io);
178
+ if (opts?.agent !== undefined && typeof opts.agent !== "boolean") {
179
+ throw new Error(`task("${name}") agent must be a boolean when provided`);
180
+ }
181
+ const agent = opts?.agent ?? prompt !== undefined;
147
182
  api.out.push({
148
183
  kind: "task",
149
184
  name,
150
185
  envelopes: api.contractEnvelopes(name),
151
186
  jobType: override,
152
187
  prompt,
188
+ agent,
153
189
  io: opts?.io,
154
190
  });
155
191
  return api.self();
@@ -158,7 +194,7 @@ registerNodeKind("task", {
158
194
  // Neither a prompt binding nor an explicit io mapping → the standard
159
195
  // service-task emission is preserved unchanged (no `linkedResources`, no
160
196
  // `ioMapping`).
161
- if (node.prompt === undefined && node.io === undefined) {
197
+ if (!hasTaskExtensions(node)) {
162
198
  api.addServiceTask(node);
163
199
  api.connect(incoming, node.name);
164
200
  return [api.newEdge(node.name)];
@@ -168,7 +204,7 @@ registerNodeKind("task", {
168
204
  // properties, recording envelopes); we only supply the `linkedResource`
169
205
  // and/or `ioMapping` delta, so the envelope/taskDefinition serialization has
170
206
  // a single source.
171
- api.addServiceTask(node, { extraExt: taskExtensions(node.prompt, node.io) });
207
+ api.addServiceTask(node, { extraExt: taskExtensions(node.agent, node.prompt, node.io) });
172
208
  api.connect(incoming, node.name);
173
209
  return [api.newEdge(node.name)];
174
210
  },
package/src/worker.ts CHANGED
@@ -84,6 +84,9 @@ export class Worker {
84
84
  private readonly onError?: WorkerOptions["onError"];
85
85
  /** job type → { workflowId, handle } */
86
86
  private readonly routes = new Map<string, { workflowId: string; handle: Handler }>();
87
+ /** Job lock timeout (ms) per derived job type, from a `run` step's `{ timeoutMs }`
88
+ * (issue #596). Overrides the worker-wide `jobTimeoutMs` for that type. */
89
+ private readonly typeTimeoutMs = new Map<string, number>();
87
90
  private running = false;
88
91
  private workers: NanoJobWorker[] = [];
89
92
 
@@ -142,6 +145,9 @@ export class Worker {
142
145
  return { variables };
143
146
  },
144
147
  });
148
+ // A step-declared job lock timeout (issue #596) applies to this step's derived
149
+ // job type, overriding the worker-wide `jobTimeoutMs` at `start()`.
150
+ if (s.timeoutMs !== undefined) this.typeTimeoutMs.set(jobType(wf.id, s.name), s.timeoutMs);
145
151
  });
146
152
  }
147
153
  }
@@ -200,7 +206,8 @@ export class Worker {
200
206
  jobType: type,
201
207
  workerName: this.name,
202
208
  maxParallelJobs: this.maxParallelJobs,
203
- jobTimeoutMs: this.jobTimeoutMs,
209
+ // A step-declared `{ timeoutMs }` (issue #596) wins over the worker-wide default.
210
+ jobTimeoutMs: this.typeTimeoutMs.get(type) ?? this.jobTimeoutMs,
204
211
  pollTimeoutMs: this.pollTimeoutMs,
205
212
  autoStart: false,
206
213
  jobHandler: (job) => this.handleJob(type, route, job),