@exaudeus/workrail 3.52.0 → 3.54.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.
@@ -238,8 +238,8 @@
238
238
  "bytes": 31
239
239
  },
240
240
  "cli-worktrain.js": {
241
- "sha256": "4f68c32b88c84d71d7a28b4ffd818d2612e5a8c61c9f59edd4415772dfd5b9ab",
242
- "bytes": 50057
241
+ "sha256": "8af6e8703639f67d1b1cda43fe533e3622e0d3805be08aa6b2e4053eeec59ae7",
242
+ "bytes": 51346
243
243
  },
244
244
  "cli.d.ts": {
245
245
  "sha256": "43e818adf60173644896298637f47b01d5819b17eda46eaa32d0c7d64724d012",
@@ -362,12 +362,12 @@
362
362
  "bytes": 12528
363
363
  },
364
364
  "cli/commands/worktrain-overview.d.ts": {
365
- "sha256": "04ba3abc659a219dc05893739c0b9c4797c5a45a67bc26647c17ab0db79c0d52",
366
- "bytes": 1327
365
+ "sha256": "d9cb907b57aaa19ee5654c1726a33cdc6b7a54e3bd24dd8f2900d18491dd94a3",
366
+ "bytes": 1393
367
367
  },
368
368
  "cli/commands/worktrain-overview.js": {
369
- "sha256": "0987dcb2518dfd44d103dd0958abae4c7e8323f314971ffdc3ead320c4c8eef9",
370
- "bytes": 6764
369
+ "sha256": "deb7e2a3fb76fbe89479b07fac69df3b8bb0cd06d63fb6a8a73046886ff7b3a7",
370
+ "bytes": 9483
371
371
  },
372
372
  "cli/commands/worktrain-pipeline.d.ts": {
373
373
  "sha256": "ea0b36c853002352c7fad167c90dcf0234d2ec1a1fb042d4fd749531c7733fc0",
@@ -465,8 +465,8 @@
465
465
  "sha256": "5fe866e54f796975dec5d8ba9983aefd86074db212d3fccd64eed04bc9f0b3da",
466
466
  "bytes": 8011
467
467
  },
468
- "console-ui/assets/index-Ce7Feod7.js": {
469
- "sha256": "4db4d03fa5fbc1e7ea2a1abac8e29cce1596beed8a4daf3a26b9237eac80128c",
468
+ "console-ui/assets/index-CoXPahi0.js": {
469
+ "sha256": "4318a19db292a33a0001ddce4540b7120b621606a8169f3c7812c27c8d6232cc",
470
470
  "bytes": 760528
471
471
  },
472
472
  "console-ui/assets/index-DGj8EsFR.css": {
@@ -474,7 +474,7 @@
474
474
  "bytes": 60631
475
475
  },
476
476
  "console-ui/index.html": {
477
- "sha256": "2169487225f552787e2a3273d8fe6ac797e171846a6dd7b98099006b1a5aa787",
477
+ "sha256": "ff31b9cb7ebf0123369286b6a7dc91fbc31fa34afc6fe0cc2d8a04f3f1ee2687",
478
478
  "bytes": 417
479
479
  },
480
480
  "console/standalone-console.d.ts": {
@@ -597,9 +597,17 @@
597
597
  "sha256": "aa47bea99cf9a5ce35d2bc375b6dd51a9fbb68d36f2b185ee0a98176f67d647d",
598
598
  "bytes": 9803
599
599
  },
600
+ "daemon/daemon-env.d.ts": {
601
+ "sha256": "4546caacd79c21b162d2d03c1cde24d4f6d232e684229828722be75d4a33920a",
602
+ "bytes": 213
603
+ },
604
+ "daemon/daemon-env.js": {
605
+ "sha256": "287a66c96730857e06375da299bf8a6d61aaf53080d94cbdbac9f0428a55a6fb",
606
+ "bytes": 1216
607
+ },
600
608
  "daemon/daemon-events.d.ts": {
601
- "sha256": "8157d89411cb535d3c08d1f0a94a7bd9b5fa4934777133c0217917f83e56d95e",
602
- "bytes": 4488
609
+ "sha256": "bfdc842cdb54a81207d88d8315a1f5e9500ac5da9265b0e4f2e9252636bd0a3a",
610
+ "bytes": 4820
603
611
  },
604
612
  "daemon/daemon-events.js": {
605
613
  "sha256": "b6841eef4634bb266faf81961c1e387b535dd64a74d58582f3f2bad8c3469d95",
@@ -1666,8 +1674,8 @@
1666
1674
  "bytes": 800
1667
1675
  },
1668
1676
  "trigger/github-queue-config.js": {
1669
- "sha256": "dd508fc3f9f58a70cdc076029154a3c1610569c611471e9b67bf1cd4be64a520",
1670
- "bytes": 7347
1677
+ "sha256": "1971310c5d8519efc22f627f3eb56c9e5e457fc90da9e32cbd09a1bbfd3cb6a8",
1678
+ "bytes": 7353
1671
1679
  },
1672
1680
  "trigger/index.d.ts": {
1673
1681
  "sha256": "a9cfd053714173e2a8cc5a282fd5b09a5c3f3001304d507facd0e12de9cc0733",
@@ -1698,8 +1706,8 @@
1698
1706
  "bytes": 792
1699
1707
  },
1700
1708
  "trigger/polling-scheduler.js": {
1701
- "sha256": "dd5532f3dff75377685fb68d78820cb5916f823626b91d6306ad7aa3d9801be5",
1702
- "bytes": 20188
1709
+ "sha256": "49b4f8b82f21d336365d5a82aa10f4541792d3a4f84c21ff0db34034f8290626",
1710
+ "bytes": 20312
1703
1711
  },
1704
1712
  "trigger/trigger-listener.d.ts": {
1705
1713
  "sha256": "92e971ab8f47c3c867860cffc01f54c4aae54fcc4ae199b9469210f4ce639423",
@@ -1714,20 +1722,20 @@
1714
1722
  "bytes": 2671
1715
1723
  },
1716
1724
  "trigger/trigger-router.js": {
1717
- "sha256": "eab2a12e9e7c5b5ef425096883e23bf12937c6838b13ea455c8f560855d9b9c6",
1718
- "bytes": 19264
1725
+ "sha256": "b7a375a50c5d9bcae8afb22fde1a05f63320343b9fe82541852fa0de1201467e",
1726
+ "bytes": 19850
1719
1727
  },
1720
1728
  "trigger/trigger-store.d.ts": {
1721
1729
  "sha256": "7afb05127d55bc3757a550dd15d4b797766b3fff29d1bfe76b303764b93322e7",
1722
1730
  "bytes": 1588
1723
1731
  },
1724
1732
  "trigger/trigger-store.js": {
1725
- "sha256": "7d1a61e6f0e01fd256f128f1fc223c0846eb020a87123bd03d2c31189f65c87b",
1726
- "bytes": 38830
1733
+ "sha256": "6c3c53bc553b8e5fef67cd907d34794309f7505994afa37f7a5dcac575e1f941",
1734
+ "bytes": 42387
1727
1735
  },
1728
1736
  "trigger/types.d.ts": {
1729
- "sha256": "dc80ac05c031f24d5916bf95319dbe73262e1ada5aa5f83bedbfe6851188f8b1",
1730
- "bytes": 3689
1737
+ "sha256": "b5fe2d2ebc1da0e27211c1321539456ab346300007f29f157bff2f774e284048",
1738
+ "bytes": 3802
1731
1739
  },
1732
1740
  "trigger/types.js": {
1733
1741
  "sha256": "45b4e4f23a6d1a2b07350196871b0c53840e5d8142b47f7acedd2f40ae7a6b73",
@@ -39,7 +39,7 @@ const fs = __importStar(require("node:fs/promises"));
39
39
  const path = __importStar(require("node:path"));
40
40
  const os = __importStar(require("node:os"));
41
41
  const result_js_1 = require("../runtime/result.js");
42
- exports.WORKRAIL_CONFIG_PATH = path.join(os.homedir(), '.workrail', 'config.json');
42
+ exports.WORKRAIL_CONFIG_PATH = path.join(os.homedir(), '.workrail', 'queue-config.json');
43
43
  async function loadQueueConfig(configPath = exports.WORKRAIL_CONFIG_PATH, env = process.env) {
44
44
  let raw;
45
45
  try {
@@ -286,14 +286,13 @@ class PollingScheduler {
286
286
  const maturityReason = describeMaturityReason(top.maturity);
287
287
  console.log(`[QueuePoll] selected #${top.issue.number} "${top.issue.title}" maturity=${top.maturity} reason="${maturityReason}"`);
288
288
  await appendQueuePollLog({ event: 'task_selected', issueNumber: top.issue.number, title: top.issue.title, maturity: top.maturity, reason: maturityReason, ts: new Date().toISOString() });
289
- if ('dispatchAdaptivePipeline' in this.router &&
290
- typeof this.router.dispatchAdaptivePipeline === 'function') {
291
- void this.router.dispatchAdaptivePipeline(workflowTrigger.goal, workflowTrigger.workspacePath, workflowTrigger.context);
292
- console.log(`[QueuePoll] dispatched via adaptivePipeline goal="${workflowTrigger.goal.slice(0, 80)}"`);
293
- }
294
- else {
295
- this.router.dispatch(workflowTrigger);
289
+ if (typeof this.router.dispatchAdaptivePipeline !== 'function') {
290
+ throw new Error('[QueuePoll] dispatchAdaptivePipeline not available on router. ' +
291
+ 'Queue poll triggers require the adaptive coordinator. ' +
292
+ 'Inject coordinatorDeps and modeExecutors in the TriggerRouter constructor.');
296
293
  }
294
+ void this.router.dispatchAdaptivePipeline(workflowTrigger.goal, workflowTrigger.workspacePath, workflowTrigger.context);
295
+ console.log(`[QueuePoll] dispatched via adaptivePipeline goal="${workflowTrigger.goal.slice(0, 80)}"`);
297
296
  for (let i = 1; i < candidates.length; i++) {
298
297
  const { issue, maturity } = candidates[i];
299
298
  console.log(`[QueuePoll] skipped #${issue.number} "${issue.title}" reason=lower_priority_${maturity}`);
@@ -255,6 +255,16 @@ class TriggerRouter {
255
255
  return { _tag: 'error', error: { kind: 'hmac_invalid' } };
256
256
  }
257
257
  }
258
+ if (trigger.dispatchCondition) {
259
+ const { payloadPath, equals } = trigger.dispatchCondition;
260
+ const extracted = extractDotPath(event.payload, payloadPath);
261
+ if (extracted !== equals) {
262
+ const actual = extracted === undefined ? 'undefined' : String(extracted);
263
+ console.log(`[TriggerRouter] dispatch skipped: condition not met ` +
264
+ `(${payloadPath}=${actual} !== ${equals}) for triggerId=${trigger.id}`);
265
+ return { _tag: 'enqueued', triggerId: trigger.id };
266
+ }
267
+ }
258
268
  let workflowContext;
259
269
  if (trigger.contextMapping?.mappings.length) {
260
270
  workflowContext = applyContextMapping(event.payload, trigger.contextMapping.mappings);
@@ -268,6 +268,50 @@ function parseTriggersYaml(content) {
268
268
  trigger.onComplete = onComplete;
269
269
  continue;
270
270
  }
271
+ if (key === 'dispatchCondition') {
272
+ lineIndex++;
273
+ const dispatchCondition = {};
274
+ while (lineIndex < lines.length) {
275
+ const dcLine = lines[lineIndex];
276
+ if (dcLine === undefined)
277
+ break;
278
+ const dcTrimmed = dcLine.trim();
279
+ if (dcTrimmed === '' || dcTrimmed.startsWith('#')) {
280
+ lineIndex++;
281
+ continue;
282
+ }
283
+ const dcIndent = dcLine.search(/\S/);
284
+ if (dcIndent <= lineIndent)
285
+ break;
286
+ const dcColonIdx = dcTrimmed.indexOf(':');
287
+ if (dcColonIdx === -1) {
288
+ return (0, result_js_1.err)({
289
+ kind: 'parse_error',
290
+ message: `Missing colon in dispatchCondition entry at line ${lineIndex + 1}: "${dcTrimmed}"`,
291
+ lineNumber: lineIndex + 1,
292
+ });
293
+ }
294
+ const dcKey = dcTrimmed.slice(0, dcColonIdx).trim();
295
+ const dcRawValue = dcTrimmed.slice(dcColonIdx + 1).trim();
296
+ if (dcRawValue !== '') {
297
+ const dcValueResult = parseScalar(dcRawValue, lineIndex + 1);
298
+ if (dcValueResult.kind === 'err')
299
+ return dcValueResult;
300
+ switch (dcKey) {
301
+ case 'payloadPath':
302
+ dispatchCondition.payloadPath = dcValueResult.value;
303
+ break;
304
+ case 'equals':
305
+ dispatchCondition.equals = dcValueResult.value;
306
+ break;
307
+ default: break;
308
+ }
309
+ }
310
+ lineIndex++;
311
+ }
312
+ trigger.dispatchCondition = dispatchCondition;
313
+ continue;
314
+ }
271
315
  if (key === 'source') {
272
316
  lineIndex++;
273
317
  const source = {};
@@ -441,9 +485,9 @@ function validateAndResolveTrigger(raw, env, workspaces = {}) {
441
485
  if (!rawId) {
442
486
  return (0, result_js_1.err)({ kind: 'missing_field', field: 'id', triggerId: '(unknown)' });
443
487
  }
488
+ const isQueuePollProvider = raw.provider?.trim() === 'github_queue_poll';
444
489
  const requiredStringFields = [
445
490
  'provider',
446
- 'workflowId',
447
491
  ];
448
492
  for (const field of requiredStringFields) {
449
493
  const v = raw[field];
@@ -451,6 +495,15 @@ function validateAndResolveTrigger(raw, env, workspaces = {}) {
451
495
  return (0, result_js_1.err)({ kind: 'missing_field', field, triggerId: rawId });
452
496
  }
453
497
  }
498
+ if (!isQueuePollProvider && !raw.workflowId?.trim()) {
499
+ return (0, result_js_1.err)({ kind: 'missing_field', field: 'workflowId', triggerId: rawId });
500
+ }
501
+ if (isQueuePollProvider && raw.workflowId?.trim()) {
502
+ console.warn(`[TriggerStore] WARNING: trigger "${rawId}" has provider='github_queue_poll' and ` +
503
+ `workflowId='${raw.workflowId.trim()}'. For queue poll triggers, workflowId is ignored -- ` +
504
+ `the adaptive coordinator determines the pipeline based on task content. ` +
505
+ `You can remove workflowId from this trigger definition.`);
506
+ }
454
507
  const provider = raw.provider.trim();
455
508
  if (!SUPPORTED_PROVIDERS.has(provider)) {
456
509
  return (0, result_js_1.err)({ kind: 'unknown_provider', provider, triggerId: rawId });
@@ -808,10 +861,23 @@ function validateAndResolveTrigger(raw, env, workspaces = {}) {
808
861
  `defines a source: block. The source: block is only used for polling providers ` +
809
862
  `(gitlab_poll, github_issues_poll, github_prs_poll, github_queue_poll). It will be ignored for this trigger.`);
810
863
  }
864
+ const resolvedWorkflowId = raw.workflowId?.trim() ?? '';
865
+ let dispatchCondition;
866
+ if (raw.dispatchCondition) {
867
+ const rawDcPayloadPath = raw.dispatchCondition.payloadPath?.trim();
868
+ const rawDcEquals = raw.dispatchCondition.equals?.trim();
869
+ if (!rawDcPayloadPath) {
870
+ return (0, result_js_1.err)({ kind: 'missing_field', field: 'dispatchCondition.payloadPath', triggerId: rawId });
871
+ }
872
+ if (rawDcEquals === undefined || rawDcEquals === '') {
873
+ return (0, result_js_1.err)({ kind: 'missing_field', field: 'dispatchCondition.equals', triggerId: rawId });
874
+ }
875
+ dispatchCondition = { payloadPath: rawDcPayloadPath, equals: rawDcEquals };
876
+ }
811
877
  const trigger = {
812
878
  id: (0, types_js_1.asTriggerId)(rawId),
813
879
  provider,
814
- workflowId: raw.workflowId.trim(),
880
+ workflowId: resolvedWorkflowId,
815
881
  workspacePath: resolvedWorkspacePath,
816
882
  goal: resolvedGoal,
817
883
  concurrencyMode,
@@ -833,6 +899,7 @@ function validateAndResolveTrigger(raw, env, workspaces = {}) {
833
899
  ...(branchStrategy !== undefined ? { branchStrategy } : {}),
834
900
  ...(baseBranch !== undefined ? { baseBranch } : {}),
835
901
  ...(branchPrefix !== undefined ? { branchPrefix } : {}),
902
+ ...(dispatchCondition !== undefined ? { dispatchCondition } : {}),
836
903
  };
837
904
  return (0, result_js_1.ok)(trigger);
838
905
  }
@@ -69,6 +69,10 @@ export interface TriggerDefinition {
69
69
  readonly contextMapping?: ContextMapping;
70
70
  readonly goalTemplate?: string;
71
71
  readonly referenceUrls?: readonly string[];
72
+ readonly dispatchCondition?: {
73
+ readonly payloadPath: string;
74
+ readonly equals: string;
75
+ };
72
76
  readonly agentConfig?: {
73
77
  readonly model?: string;
74
78
  readonly maxSessionMinutes?: number;
@@ -0,0 +1,97 @@
1
+ # Design: dispatchCondition Filter and Adaptive Queue Routing
2
+
3
+ ## Problem Understanding
4
+
5
+ ### Tensions
6
+
7
+ 1. **Silent skip vs new API surface**: When `dispatchCondition` is not met, the HTTP caller already received a 202 response. Adding a new `_tag: 'skipped'` to `RouteResult` would require HTTP handler changes (the 202 was already sent). Reusing `{ _tag: 'enqueued' }` preserves the existing API contract with zero surface change. Observability is provided via a log line.
8
+
9
+ 2. **Backward compat vs clean schema for `workflowId` on queue triggers**: `workflowId: string` is required in `TriggerDefinition`. Making it optional (`string | undefined`) would cascade to all callers: `dispatch()` queue key, `maybeRunDelivery()` attribution, `WorkflowTrigger.workflowId`. Using a `''` sentinel keeps the interface unchanged while allowing queue poll triggers to omit `workflowId` in YAML.
10
+
11
+ 3. **Type guard vs hard failure for `dispatchAdaptivePipeline`**: The existing type-guard pattern (`'dispatchAdaptivePipeline' in this.router`) allows test fakes that only implement `dispatch()`. The new requirement is to throw when `dispatchAdaptivePipeline` is unavailable. This changes a soft-fallback to a hard failure -- intentional for production correctness.
12
+
13
+ 4. **Validate at boundary vs trust inside**: `dispatchCondition.payloadPath` and `dispatchCondition.equals` must both be validated as present strings at parse time (trigger-store.ts), not at dispatch time (trigger-router.ts). This follows the existing pattern for all parsed fields.
14
+
15
+ ### Likely Seam
16
+
17
+ - **`dispatchCondition` check**: `route()` in trigger-router.ts, after HMAC validation, before context mapping. This is the correct seam -- HMAC validates authenticity, then condition gates dispatch, then context mapping applies to trusted+relevant payloads.
18
+ - **Adaptive routing**: `doPollGitHubQueue()` in polling-scheduler.ts. The queue poller owns the process and calls the coordinator as a function.
19
+
20
+ ### What Makes It Hard
21
+
22
+ - `extractDotPath()` in trigger-router.ts is a private module function -- but `dispatchCondition` check is in the same file, so it's directly accessible.
23
+ - The YAML parser's sub-object block handling requires adding `dispatchCondition` as a named key case BEFORE the `if (rawValue === '')` early-skip falls through. If we miss this, `dispatchCondition:` (empty value) silently skips the block.
24
+ - `workflowId` required-field check runs before provider validation in `validateAndResolveTrigger`. Must restructure slightly to skip `workflowId` check for `github_queue_poll`.
25
+
26
+ ## Philosophy Constraints
27
+
28
+ **Honored:**
29
+ - Immutability by default -- new `dispatchCondition` fields are `readonly`
30
+ - Validate at boundaries -- `payloadPath` and `equals` validated at parse time in trigger-store.ts
31
+ - YAGNI with discipline -- equals-only MVP, no regex/AND/OR
32
+ - Exhaustiveness -- no new `RouteResult` variants (reuse `enqueued`)
33
+ - Document 'why' -- comments explain the skip behavior and the backward-compat warning
34
+
35
+ **Conflict:**
36
+ - 'Errors are data' vs throw for missing `dispatchAdaptivePipeline`: the spec explicitly requires a throw rather than returning a Result. Rationale: misconfiguration at construction time is a programmer error, not a domain error. Spec overrides philosophy here.
37
+
38
+ ## Impact Surface
39
+
40
+ - `TriggerDefinition` (types.ts): new optional field, no breaking change
41
+ - `ParsedTriggerRaw` (trigger-store.ts): new `dispatchCondition` sub-object, YAML block parser, validation
42
+ - `route()` (trigger-router.ts): new `dispatchCondition` check after HMAC
43
+ - `doPollGitHubQueue()` (polling-scheduler.ts): removes type-guard fallback
44
+ - Tests: new tests in trigger-router.test.ts and polling-scheduler.test.ts
45
+ - **Not affected**: `src/mcp/`, `WorkflowTrigger`, `RouteResult` type shape (same variants), `maybeRunDelivery`
46
+
47
+ ## Candidates
48
+
49
+ ### Candidate 1: Exact Spec Implementation (Selected)
50
+
51
+ **Summary:** Add `dispatchCondition` as a sub-object block in trigger-store.ts (following `agentConfig` pattern), check in `route()` after HMAC using existing `extractDotPath()`, use `''` sentinel for `workflowId` in queue poll, throw Error in `doPollGitHubQueue` when `dispatchAdaptivePipeline` unavailable.
52
+
53
+ **Tensions resolved:** Silent skip (enqueued tag reuse), backward compat (no interface change), scope control (4 files only).
54
+
55
+ **Tensions accepted:** Slight looseness from `''` sentinel (not a true optional), philosophy conflict on throw vs Result.
56
+
57
+ **Boundary:** All 4 trigger files. No cross-file interface changes.
58
+
59
+ **Why best-fit:** `WorkflowTrigger.workflowId` is also `string` (required), so making `TriggerDefinition.workflowId` optional would cascade to `WorkflowTrigger` and all builders. Queue poll never uses `workflowId` in its dispatch path (calls `dispatchAdaptivePipeline(goal, workspace, context)` directly), so the sentinel has zero runtime impact.
60
+
61
+ **Failure mode:** `''` sentinel leaks into delivery logs if queue poll somehow runs `maybeRunDelivery`. Mitigated: queue triggers don't set `autoCommit: true` and `maybeRunDelivery` gates on `result._tag === 'success'` from `runWorkflowFn`, which is never called for queue poll (adaptive coordinator handles dispatch).
62
+
63
+ **Repo pattern:** Follows `agentConfig` block parsing exactly. Follows `validateHmac` early-return pattern. Adapts `dispatchAdaptivePipeline` Option B design.
64
+
65
+ **Gains:** Minimal blast radius, zero interface churn, backward compatible.
66
+
67
+ **Gives up:** Type-level optionality for `workflowId` in queue triggers.
68
+
69
+ **Scope:** Best-fit -- 4 files, no cascades.
70
+
71
+ **Philosophy:** Honors immutability, validate at boundaries, YAGNI. One conflict: throw vs Result (spec overrides).
72
+
73
+ ---
74
+
75
+ ### Candidate 2: `workflowId?: string` Optional in TriggerDefinition (Rejected)
76
+
77
+ **Summary:** Make `workflowId` optional at the type level, handle `undefined` at all callsites.
78
+
79
+ **Rejected because:** Cascades to `WorkflowTrigger.workflowId` (also required), `dispatch()` queue key, `maybeRunDelivery` attribution, all three `build*WorkflowTrigger` helpers. Unnecessary churn for a field that is simply unused in the queue poll path. Scope is too broad.
80
+
81
+ ## Comparison and Recommendation
82
+
83
+ Candidate 1 is the clear winner. Candidate 2 is too broad. No other candidates are meaningfully different.
84
+
85
+ **Recommendation: Candidate 1.**
86
+
87
+ ## Self-Critique
88
+
89
+ **Strongest counter-argument:** The `''` sentinel is an implicit contract. A future developer reading `trigger.workflowId === ''` in a log won't know it means 'queue poll, adaptive routing'. A comment in the code mitigates this.
90
+
91
+ **Pivot conditions:**
92
+ - If a future feature needs to dispatch queue poll triggers to specific workflows (not just adaptive), `workflowId` would become meaningful again. At that point, add explicit support rather than reactivating the ignored field.
93
+ - If `WorkflowTrigger.workflowId` becomes optional in a future refactor, revisit making `TriggerDefinition.workflowId` optional too.
94
+
95
+ ## Open Questions for the Main Agent
96
+
97
+ None. The spec is fully prescriptive. The only implementation decision (sentinel vs optional) is resolved above.
@@ -0,0 +1,168 @@
1
+ # Implementation Plan: dispatchCondition Filter and Adaptive Queue Routing
2
+
3
+ ## Problem Statement
4
+
5
+ Two deficiencies in the trigger system:
6
+ 1. Generic webhook triggers have no payload-based dispatch filter. In queue use cases (e.g., only fire when `assignee.login === 'worktrain-etienneb'`), every webhook fires regardless of payload content.
7
+ 2. `github_queue_poll` triggers can silently fall back to `dispatch()` instead of always routing through the adaptive coordinator. This is wrong -- queue poll sessions MUST always use the adaptive coordinator.
8
+
9
+ ## Acceptance Criteria
10
+
11
+ 1. `TriggerDefinition.dispatchCondition` field exists with `payloadPath: string` and `equals: string` subfields (both `readonly`)
12
+ 2. `dispatchCondition` is parsed from triggers.yml sub-object block (same syntax as `agentConfig`)
13
+ 3. `dispatchCondition.payloadPath` and `dispatchCondition.equals` are validated as required strings when the block is present
14
+ 4. In `route()`, when `dispatchCondition` is set and the extracted payload value does NOT strictly equal `equals`: return `{ _tag: 'enqueued' }` silently (no dispatch) with a debug log
15
+ 5. When condition IS met (or absent): dispatch proceeds normally
16
+ 6. `doPollGitHubQueue()` ALWAYS calls `dispatchAdaptivePipeline()` -- never falls back to `dispatch()`
17
+ 7. When `dispatchAdaptivePipeline` is unavailable: throw Error with clear message (NOT silent fallback)
18
+ 8. `workflowId` is no longer required in triggers.yml for `github_queue_poll` providers
19
+ 9. Existing triggers.yml files with `workflowId` on a queue trigger still parse without error (log warning it's ignored)
20
+ 10. `npm run build` clean
21
+ 11. All specified tests pass, no regressions in `npx vitest run`
22
+
23
+ ## Non-Goals
24
+
25
+ - No regex matching in `dispatchCondition` (MVP: equals-only)
26
+ - No AND/OR or nested conditions
27
+ - No changes to `src/mcp/` (scope locked to 4 trigger files)
28
+ - No changes to `RouteResult` type variants (reuse `enqueued` for skipped)
29
+ - No changes to `WorkflowTrigger.workflowId` type signature
30
+
31
+ ## Philosophy-Driven Constraints
32
+
33
+ - All new fields in `TriggerDefinition`: `readonly`
34
+ - Validate `dispatchCondition` at parse time (trigger-store.ts), not at dispatch time
35
+ - Use strict identity (`===`) for `dispatchCondition` comparison -- no type coercion
36
+ - Comment at `workflowId` sentinel explaining why `''` is safe
37
+ - Throw for missing `dispatchAdaptivePipeline` (programmer error, not domain error)
38
+
39
+ ## Invariants
40
+
41
+ 1. `dispatchCondition` absent = always dispatch (current behavior, unchanged)
42
+ 2. `dispatchCondition.payloadPath` and `dispatchCondition.equals` always co-present (validated at parse time)
43
+ 3. `dispatchCondition` check runs AFTER HMAC validation, BEFORE context mapping application
44
+ 4. `route()` return type never changes -- `{ _tag: 'enqueued' }` for both dispatch and skip
45
+ 5. `doPollGitHubQueue()` never calls `this.router.dispatch()` -- always `dispatchAdaptivePipeline`
46
+ 6. `workflowId: ''` sentinel for queue poll -- never forwarded to adaptive dispatcher (only goal/workspace/context)
47
+ 7. Throw from `doPollGitHubQueue` is caught by `runPollCycle` try/catch -- not a daemon crash
48
+
49
+ ## Selected Approach
50
+
51
+ **Candidate 1: Exact spec implementation with FM5 correction (strict equals)**
52
+
53
+ - `types.ts`: Add `dispatchCondition?: { readonly payloadPath: string; readonly equals: string }` to `TriggerDefinition`
54
+ - `trigger-store.ts`: Add `dispatchCondition` sub-object block to YAML parser (like `agentConfig`), validate both fields present, assemble onto `TriggerDefinition`
55
+ - `trigger-router.ts`: After HMAC check in `route()`, if `trigger.dispatchCondition` set, extract via `extractDotPath()`, check `extracted === condition.equals`, return `{ _tag: 'enqueued' }` with debug log if mismatch
56
+ - `polling-scheduler.ts`: Remove type-guard fallback in `doPollGitHubQueue()`, always call `dispatchAdaptivePipeline`, throw Error if unavailable
57
+
58
+ **Runner-up rejected:** Optional `workflowId` -- cascades to WorkflowTrigger and all builders, unnecessary blast radius.
59
+
60
+ ## Vertical Slices
61
+
62
+ ### Slice 1: types.ts -- Add dispatchCondition to TriggerDefinition
63
+ - Add JSDoc comment explaining purpose, payloadPath syntax, when absent behavior
64
+ - Both fields `readonly string`
65
+ - File: `src/trigger/types.ts`
66
+ - Done when: TypeScript compiles, new field visible in TriggerDefinition
67
+
68
+ ### Slice 2: trigger-store.ts -- Parse and validate dispatchCondition
69
+ - Add `dispatchCondition?: { payloadPath?: string; equals?: string }` to `ParsedTriggerRaw`
70
+ - Add `'dispatchCondition'` to `setTriggerField` as known key (maps to sub-object block)
71
+ - Add `if (key === 'dispatchCondition')` block handler in YAML parser (before `rawValue === ''` check)
72
+ - Validate both payloadPath and equals present strings when block set, return TriggerStoreError if either missing
73
+ - Assemble onto TriggerDefinition in trigger assembly block
74
+ - Add workflowId conditional skip for github_queue_poll (with backward-compat warning when present)
75
+ - File: `src/trigger/trigger-store.ts`
76
+ - Done when: valid YAML with dispatchCondition parses correctly; missing field returns error; queue poll without workflowId parses; queue poll with workflowId logs warning
77
+
78
+ ### Slice 3: trigger-router.ts -- Check dispatchCondition in route()
79
+ - After HMAC validation block, before context mapping
80
+ - Extract via `extractDotPath(event.payload, condition.payloadPath)`
81
+ - If `extracted !== condition.equals`: log `[TriggerRouter] dispatch skipped: condition not met (${payloadPath}=${actual} !== ${equals})`, return `{ _tag: 'enqueued', triggerId: trigger.id }`
82
+ - Strictly BEFORE `workflowContext` building and `workflowTrigger` object construction
83
+ - File: `src/trigger/trigger-router.ts`
84
+ - Done when: condition check blocks dispatch; matching condition allows dispatch; absent condition dispatches normally
85
+
86
+ ### Slice 4: polling-scheduler.ts -- Always use adaptive, throw when unavailable
87
+ - Remove the type-guard `if ('dispatchAdaptivePipeline' in this.router && typeof ...) { ... } else { this.router.dispatch(workflowTrigger); }` block
88
+ - Replace with: check if `typeof (this.router as { dispatchAdaptivePipeline?: unknown }).dispatchAdaptivePipeline !== 'function'` -> throw Error
89
+ - Then call `await this.router.dispatchAdaptivePipeline(workflowTrigger.goal, workflowTrigger.workspacePath, workflowTrigger.context)`
90
+ - Add comment: `// Always use adaptive pipeline for queue poll triggers. workflowId from triggers.yml is intentionally ignored -- the adaptive coordinator decides the pipeline based on task content.`
91
+ - File: `src/trigger/polling-scheduler.ts`
92
+ - Done when: adaptive always called; test fake without method causes throw; log line updated
93
+
94
+ ### Slice 5: Tests
95
+ - **trigger-router.test.ts**: 3 new tests for dispatchCondition
96
+ - **polling-scheduler.test.ts**: 2 new tests for queue poll adaptive routing + update existing fakes
97
+ - Files: `tests/unit/trigger-router.test.ts`, `tests/unit/polling-scheduler.test.ts`
98
+ - Done when: all 5 new tests pass; existing tests pass; no regressions
99
+
100
+ ## Test Design
101
+
102
+ ### Tests to Update in polling-scheduler.test.ts
103
+ - `makeRouter()` function: add `dispatchAdaptivePipeline: vi.fn().mockResolvedValue({ kind: 'merged' })` to the fake router object. Existing tests that use `makeRouter()` for gitlab_poll/github triggers won't be affected (doPollGitHub doesn't use this method).
104
+
105
+ ### New Tests
106
+
107
+ **trigger-router.test.ts:**
108
+ ```typescript
109
+ describe('TriggerRouter.route dispatchCondition', () => {
110
+ it('dispatches when dispatchCondition is met (extracted value equals condition.equals)', async () => {
111
+ // trigger with dispatchCondition: { payloadPath: '$.assignee.login', equals: 'worktrain-bot' }
112
+ // payload: { assignee: { login: 'worktrain-bot' } }
113
+ // expect: runWorkflow called
114
+ });
115
+ it('skips dispatch when dispatchCondition not met (wrong value)', async () => {
116
+ // payload: { assignee: { login: 'other-user' } }
117
+ // expect: runWorkflow NOT called, returns { _tag: 'enqueued' }
118
+ });
119
+ it('skips dispatch when dispatchCondition path not found in payload', async () => {
120
+ // payload: {} (no assignee field)
121
+ // expect: runWorkflow NOT called (undefined !== 'worktrain-bot')
122
+ });
123
+ });
124
+ ```
125
+
126
+ **polling-scheduler.test.ts:**
127
+ ```typescript
128
+ describe('doPollGitHubQueue adaptive routing', () => {
129
+ it('always calls dispatchAdaptivePipeline, never dispatch()', async () => {
130
+ // fake router with dispatchAdaptivePipeline: vi.fn(), dispatch: vi.fn()
131
+ // expect: dispatchAdaptivePipeline called, dispatch NOT called
132
+ });
133
+ it('throws when dispatchAdaptivePipeline is not available on router', async () => {
134
+ // fake router with ONLY dispatch: vi.fn() (no dispatchAdaptivePipeline)
135
+ // expect: doPoll throws Error
136
+ // (runPollCycle catches it and logs warn -- test calls doPoll directly)
137
+ });
138
+ });
139
+ ```
140
+
141
+ ## Risk Register
142
+
143
+ | Risk | Severity | Mitigation |
144
+ |---|---|---|
145
+ | YAML parser misses dispatchCondition block | Low | Add key case before rawValue === '' check |
146
+ | Strict equals vs coerced comparison (FM5) | Medium | Use extracted === condition.equals |
147
+ | Throw in doPollGitHubQueue not caught | Low | runPollCycle try/catch already in place |
148
+ | Existing queue poll test fakes break | Low | Update makeRouter() to add dispatchAdaptivePipeline |
149
+ | workflowId '' sentinel leaks to delivery | Low | Comment + queue poll never runs route() |
150
+
151
+ ## PR Packaging Strategy
152
+
153
+ Single PR: `feat/dispatch-condition-and-adaptive-queue`
154
+ All 5 slices in one commit: `feat(trigger): add dispatchCondition filter and route queue triggers through adaptive coordinator`
155
+
156
+ ## Philosophy Alignment Per Slice
157
+
158
+ | Slice | Principle | Status |
159
+ |---|---|---|
160
+ | types.ts | Immutability by default | Satisfied (readonly fields) |
161
+ | types.ts | Make illegal states unrepresentable | Satisfied (both fields required when block present) |
162
+ | trigger-store.ts | Validate at boundaries | Satisfied (parse-time validation) |
163
+ | trigger-store.ts | Errors are data | Satisfied (returns TriggerStoreError) |
164
+ | trigger-router.ts | Compose with small pure functions | Satisfied (reuse extractDotPath) |
165
+ | trigger-router.ts | Type safety | Satisfied (strict === comparison) |
166
+ | polling-scheduler.ts | Errors are data | Tension (throw instead of Result) -- spec overrides |
167
+ | polling-scheduler.ts | Exhaustiveness | Satisfied (no fallback path) |
168
+ | Tests | Prefer fakes over mocks | Satisfied (fake router with typed methods) |
@@ -0,0 +1,61 @@
1
+ # Design Review Findings: dispatchCondition and Adaptive Queue
2
+
3
+ ## Tradeoff Review
4
+
5
+ All four accepted tradeoffs verified as safe:
6
+ 1. **Silent skip (`{ _tag: 'enqueued' }`)**: HTTP 202 already sent before route() evaluates. trigger-listener.ts maps enqueued -> 202 unconditionally. Log line provides observability. No breaking change.
7
+ 2. **`workflowId: ''` sentinel**: Never forwarded in adaptive dispatch path (only goal/workspace/context passed to dispatchAdaptivePipeline). No delivery path runs for queue poll.
8
+ 3. **Throw for missing `dispatchAdaptivePipeline`**: Caught by runPollCycle try/catch (lines 186-196), converted to console.warn. Not a daemon crash.
9
+ 4. **YAML sub-object block for `dispatchCondition`**: Handled before the `rawValue === ''` early-skip, following exact `agentConfig` pattern.
10
+
11
+ ## Failure Mode Review
12
+
13
+ | Failure Mode | Status | Notes |
14
+ |---|---|---|
15
+ | YAML parser misses dispatchCondition block | Handled | Add key case before rawValue === '' check |
16
+ | dispatchCondition on polling trigger | Non-issue | route() never called for polling triggers |
17
+ | workflowId sentinel leaks to delivery | Handled | Delivery only runs from route() path |
18
+ | Throw crashes daemon | Handled | Caught by runPollCycle try/catch |
19
+ | **FM5: Strict equals comparison** | **Requires fix** | Must use `extracted === equals` (strict), not `String(extracted) === equals` |
20
+
21
+ ## Runner-Up / Simpler Alternative Review
22
+
23
+ - Candidate 2 (optional workflowId): no elements worth borrowing. Blast radius unjustified.
24
+ - Simpler flat-field alternative: rejected -- spec explicitly shows sub-object YAML format.
25
+ - No hybrid improvements beyond FM5 correction.
26
+
27
+ ## Philosophy Alignment
28
+
29
+ **Satisfied:** Immutability, validate at boundaries, YAGNI, compose with small functions, exhaustiveness.
30
+
31
+ **Acceptable tensions:**
32
+ - 'Errors as data' vs throw: covered by try/catch in runPollCycle; represents programmer error not domain error.
33
+ - Illegal states vs workflowId sentinel: never read in queue poll dispatch path.
34
+
35
+ ## Findings
36
+
37
+ ### YELLOW: FM5 -- Strict Equals Comparison
38
+
39
+ The spec says 'strictly equals this string'. The naive implementation might use `String(extractDotPath(...)) === condition.equals` which would cause type coercion (number 42 matches string '42'). Must use strict identity: `extracted === condition.equals`.
40
+
41
+ **Impact:** Behavioral correctness -- wrong values could trigger dispatch (if payload has numeric field and equals is its string representation).
42
+
43
+ **Fix:** Implement as `const extracted = extractDotPath(payload, condition.payloadPath); return extracted === condition.equals` (strict identity, no coercion).
44
+
45
+ ### YELLOW: workflowId Sentinel Comment
46
+
47
+ The `''` sentinel for queue poll `workflowId` is an implicit contract. A comment is needed at the parse site explaining why it's safe.
48
+
49
+ **Impact:** Maintainability -- future developer may not understand why `''` is accepted.
50
+
51
+ **Fix:** Add comment: `// workflowId is intentionally '' for github_queue_poll -- the adaptive coordinator determines the pipeline. This field is never used in queue poll dispatch.`
52
+
53
+ ## Recommended Revisions
54
+
55
+ 1. Use `extracted === condition.equals` (not `String(extracted) === condition.equals`) in route()
56
+ 2. Add comment at workflowId sentinel assignment in trigger-store.ts
57
+ 3. Update existing queue poll test fake in polling-scheduler.test.ts to include `dispatchAdaptivePipeline` method
58
+
59
+ ## Residual Concerns
60
+
61
+ None blocking. Both findings are Yellow (implementation corrections, not design flaws). The design is sound.