@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.
- package/dist/cli/commands/worktrain-overview.d.ts +1 -0
- package/dist/cli/commands/worktrain-overview.js +64 -0
- package/dist/cli-worktrain.js +20 -0
- package/dist/console-ui/assets/{index-Ce7Feod7.js → index-CoXPahi0.js} +1 -1
- package/dist/console-ui/index.html +1 -1
- package/dist/daemon/daemon-env.d.ts +5 -0
- package/dist/daemon/daemon-env.js +36 -0
- package/dist/daemon/daemon-events.d.ts +11 -1
- package/dist/manifest.json +29 -21
- package/dist/trigger/github-queue-config.js +1 -1
- package/dist/trigger/polling-scheduler.js +6 -7
- package/dist/trigger/trigger-router.js +10 -0
- package/dist/trigger/trigger-store.js +69 -2
- package/dist/trigger/types.d.ts +4 -0
- package/docs/design/dispatch-condition-and-adaptive-queue.md +97 -0
- package/docs/design/dispatch-condition-implementation-plan.md +168 -0
- package/docs/design/dispatch-condition-review-findings.md +61 -0
- package/docs/ideas/backlog.md +163 -0
- package/package.json +1 -1
package/dist/manifest.json
CHANGED
|
@@ -238,8 +238,8 @@
|
|
|
238
238
|
"bytes": 31
|
|
239
239
|
},
|
|
240
240
|
"cli-worktrain.js": {
|
|
241
|
-
"sha256": "
|
|
242
|
-
"bytes":
|
|
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": "
|
|
366
|
-
"bytes":
|
|
365
|
+
"sha256": "d9cb907b57aaa19ee5654c1726a33cdc6b7a54e3bd24dd8f2900d18491dd94a3",
|
|
366
|
+
"bytes": 1393
|
|
367
367
|
},
|
|
368
368
|
"cli/commands/worktrain-overview.js": {
|
|
369
|
-
"sha256": "
|
|
370
|
-
"bytes":
|
|
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-
|
|
469
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
602
|
-
"bytes":
|
|
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": "
|
|
1670
|
-
"bytes":
|
|
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": "
|
|
1702
|
-
"bytes":
|
|
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": "
|
|
1718
|
-
"bytes":
|
|
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": "
|
|
1726
|
-
"bytes":
|
|
1733
|
+
"sha256": "6c3c53bc553b8e5fef67cd907d34794309f7505994afa37f7a5dcac575e1f941",
|
|
1734
|
+
"bytes": 42387
|
|
1727
1735
|
},
|
|
1728
1736
|
"trigger/types.d.ts": {
|
|
1729
|
-
"sha256": "
|
|
1730
|
-
"bytes":
|
|
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 (
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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:
|
|
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
|
}
|
package/dist/trigger/types.d.ts
CHANGED
|
@@ -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.
|