@renseiai/agentfactory 0.8.8 → 0.8.9
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/src/config/index.d.ts +1 -1
- package/dist/src/config/index.d.ts.map +1 -1
- package/dist/src/config/index.js +1 -1
- package/dist/src/config/repository-config.d.ts +23 -0
- package/dist/src/config/repository-config.d.ts.map +1 -1
- package/dist/src/config/repository-config.js +27 -0
- package/dist/src/config/repository-config.test.js +140 -1
- package/dist/src/governor/decision-engine.d.ts +3 -0
- package/dist/src/governor/decision-engine.d.ts.map +1 -1
- package/dist/src/governor/decision-engine.js +11 -0
- package/dist/src/governor/decision-engine.test.js +33 -0
- package/dist/src/governor/governor-types.d.ts +1 -1
- package/dist/src/governor/governor-types.d.ts.map +1 -1
- package/dist/src/governor/governor.d.ts +17 -1
- package/dist/src/governor/governor.d.ts.map +1 -1
- package/dist/src/governor/governor.js +112 -1
- package/dist/src/governor/governor.test.js +155 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/orchestrator/issue-tracker-client.d.ts +4 -0
- package/dist/src/orchestrator/issue-tracker-client.d.ts.map +1 -1
- package/dist/src/orchestrator/orchestrator.d.ts.map +1 -1
- package/dist/src/orchestrator/orchestrator.js +24 -0
- package/dist/src/orchestrator/parse-work-result.d.ts.map +1 -1
- package/dist/src/orchestrator/parse-work-result.js +6 -0
- package/dist/src/orchestrator/parse-work-result.test.js +19 -0
- package/dist/src/providers/index.d.ts +64 -1
- package/dist/src/providers/index.d.ts.map +1 -1
- package/dist/src/providers/index.js +132 -1
- package/dist/src/providers/index.test.js +340 -2
- package/dist/src/routing/index.d.ts +7 -0
- package/dist/src/routing/index.d.ts.map +1 -0
- package/dist/src/routing/index.js +6 -0
- package/dist/src/routing/observation-recorder.d.ts +19 -0
- package/dist/src/routing/observation-recorder.d.ts.map +1 -0
- package/dist/src/routing/observation-recorder.js +73 -0
- package/dist/src/routing/observation-recorder.test.d.ts +2 -0
- package/dist/src/routing/observation-recorder.test.d.ts.map +1 -0
- package/dist/src/routing/observation-recorder.test.js +322 -0
- package/dist/src/routing/observation-store.d.ts +40 -0
- package/dist/src/routing/observation-store.d.ts.map +1 -0
- package/dist/src/routing/observation-store.js +1 -0
- package/dist/src/routing/observation-store.test.d.ts +2 -0
- package/dist/src/routing/observation-store.test.d.ts.map +1 -0
- package/dist/src/routing/observation-store.test.js +138 -0
- package/dist/src/routing/posterior-store.d.ts +12 -0
- package/dist/src/routing/posterior-store.d.ts.map +1 -0
- package/dist/src/routing/posterior-store.js +13 -0
- package/dist/src/routing/posterior-store.test.d.ts +2 -0
- package/dist/src/routing/posterior-store.test.d.ts.map +1 -0
- package/dist/src/routing/posterior-store.test.js +37 -0
- package/dist/src/routing/reward.d.ts +16 -0
- package/dist/src/routing/reward.d.ts.map +1 -0
- package/dist/src/routing/reward.js +29 -0
- package/dist/src/routing/reward.test.d.ts +2 -0
- package/dist/src/routing/reward.test.d.ts.map +1 -0
- package/dist/src/routing/reward.test.js +210 -0
- package/dist/src/routing/routing-engine.d.ts +20 -0
- package/dist/src/routing/routing-engine.d.ts.map +1 -0
- package/dist/src/routing/routing-engine.js +113 -0
- package/dist/src/routing/routing-engine.test.d.ts +2 -0
- package/dist/src/routing/routing-engine.test.d.ts.map +1 -0
- package/dist/src/routing/routing-engine.test.js +310 -0
- package/dist/src/routing/types.d.ts +157 -0
- package/dist/src/routing/types.d.ts.map +1 -0
- package/dist/src/routing/types.js +68 -0
- package/dist/src/routing/types.test.d.ts +2 -0
- package/dist/src/routing/types.test.d.ts.map +1 -0
- package/dist/src/routing/types.test.js +184 -0
- package/dist/src/templates/types.d.ts +3 -0
- package/dist/src/templates/types.d.ts.map +1 -1
- package/dist/src/templates/types.js +2 -0
- package/dist/src/workflow/agent-cancellation.d.ts +37 -0
- package/dist/src/workflow/agent-cancellation.d.ts.map +1 -0
- package/dist/src/workflow/agent-cancellation.js +41 -0
- package/dist/src/workflow/agent-cancellation.test.d.ts +2 -0
- package/dist/src/workflow/agent-cancellation.test.d.ts.map +1 -0
- package/dist/src/workflow/agent-cancellation.test.js +86 -0
- package/dist/src/workflow/concurrency-semaphore.d.ts +21 -0
- package/dist/src/workflow/concurrency-semaphore.d.ts.map +1 -0
- package/dist/src/workflow/concurrency-semaphore.js +46 -0
- package/dist/src/workflow/concurrency-semaphore.test.d.ts +2 -0
- package/dist/src/workflow/concurrency-semaphore.test.d.ts.map +1 -0
- package/dist/src/workflow/concurrency-semaphore.test.js +183 -0
- package/dist/src/workflow/gate-state.d.ts +115 -0
- package/dist/src/workflow/gate-state.d.ts.map +1 -0
- package/dist/src/workflow/gate-state.js +185 -0
- package/dist/src/workflow/gate-state.test.d.ts +2 -0
- package/dist/src/workflow/gate-state.test.d.ts.map +1 -0
- package/dist/src/workflow/gate-state.test.js +251 -0
- package/dist/src/workflow/gates/gate-evaluator.d.ts +119 -0
- package/dist/src/workflow/gates/gate-evaluator.d.ts.map +1 -0
- package/dist/src/workflow/gates/gate-evaluator.js +243 -0
- package/dist/src/workflow/gates/gate-evaluator.test.d.ts +2 -0
- package/dist/src/workflow/gates/gate-evaluator.test.d.ts.map +1 -0
- package/dist/src/workflow/gates/gate-evaluator.test.js +240 -0
- package/dist/src/workflow/gates/signal-gate.d.ts +114 -0
- package/dist/src/workflow/gates/signal-gate.d.ts.map +1 -0
- package/dist/src/workflow/gates/signal-gate.js +216 -0
- package/dist/src/workflow/gates/signal-gate.test.d.ts +2 -0
- package/dist/src/workflow/gates/signal-gate.test.d.ts.map +1 -0
- package/dist/src/workflow/gates/signal-gate.test.js +199 -0
- package/dist/src/workflow/gates/timeout-engine.d.ts +96 -0
- package/dist/src/workflow/gates/timeout-engine.d.ts.map +1 -0
- package/dist/src/workflow/gates/timeout-engine.js +162 -0
- package/dist/src/workflow/gates/timeout-engine.test.d.ts +2 -0
- package/dist/src/workflow/gates/timeout-engine.test.d.ts.map +1 -0
- package/dist/src/workflow/gates/timeout-engine.test.js +186 -0
- package/dist/src/workflow/gates/timer-gate.d.ts +125 -0
- package/dist/src/workflow/gates/timer-gate.d.ts.map +1 -0
- package/dist/src/workflow/gates/timer-gate.js +381 -0
- package/dist/src/workflow/gates/timer-gate.test.d.ts +2 -0
- package/dist/src/workflow/gates/timer-gate.test.d.ts.map +1 -0
- package/dist/src/workflow/gates/timer-gate.test.js +211 -0
- package/dist/src/workflow/gates/webhook-gate.d.ts +132 -0
- package/dist/src/workflow/gates/webhook-gate.d.ts.map +1 -0
- package/dist/src/workflow/gates/webhook-gate.js +216 -0
- package/dist/src/workflow/gates/webhook-gate.test.d.ts +2 -0
- package/dist/src/workflow/gates/webhook-gate.test.d.ts.map +1 -0
- package/dist/src/workflow/gates/webhook-gate.test.js +182 -0
- package/dist/src/workflow/index.d.ts +23 -2
- package/dist/src/workflow/index.d.ts.map +1 -1
- package/dist/src/workflow/index.js +15 -1
- package/dist/src/workflow/parallelism-executor.d.ts +25 -0
- package/dist/src/workflow/parallelism-executor.d.ts.map +1 -0
- package/dist/src/workflow/parallelism-executor.js +53 -0
- package/dist/src/workflow/parallelism-executor.test.d.ts +2 -0
- package/dist/src/workflow/parallelism-executor.test.d.ts.map +1 -0
- package/dist/src/workflow/parallelism-executor.test.js +191 -0
- package/dist/src/workflow/parallelism-types.d.ts +80 -0
- package/dist/src/workflow/parallelism-types.d.ts.map +1 -0
- package/dist/src/workflow/parallelism-types.js +8 -0
- package/dist/src/workflow/phase-context-injector.d.ts +29 -0
- package/dist/src/workflow/phase-context-injector.d.ts.map +1 -0
- package/dist/src/workflow/phase-context-injector.js +43 -0
- package/dist/src/workflow/phase-context-injector.test.d.ts +2 -0
- package/dist/src/workflow/phase-context-injector.test.d.ts.map +1 -0
- package/dist/src/workflow/phase-context-injector.test.js +123 -0
- package/dist/src/workflow/phase-output-collector.d.ts +39 -0
- package/dist/src/workflow/phase-output-collector.d.ts.map +1 -0
- package/dist/src/workflow/phase-output-collector.js +141 -0
- package/dist/src/workflow/phase-output-collector.test.d.ts +2 -0
- package/dist/src/workflow/phase-output-collector.test.d.ts.map +1 -0
- package/dist/src/workflow/phase-output-collector.test.js +179 -0
- package/dist/src/workflow/strategies/fan-in-strategy.d.ts +21 -0
- package/dist/src/workflow/strategies/fan-in-strategy.d.ts.map +1 -0
- package/dist/src/workflow/strategies/fan-in-strategy.js +92 -0
- package/dist/src/workflow/strategies/fan-in-strategy.test.d.ts +2 -0
- package/dist/src/workflow/strategies/fan-in-strategy.test.d.ts.map +1 -0
- package/dist/src/workflow/strategies/fan-in-strategy.test.js +182 -0
- package/dist/src/workflow/strategies/fan-out-strategy.d.ts +16 -0
- package/dist/src/workflow/strategies/fan-out-strategy.d.ts.map +1 -0
- package/dist/src/workflow/strategies/fan-out-strategy.js +47 -0
- package/dist/src/workflow/strategies/fan-out-strategy.test.d.ts +2 -0
- package/dist/src/workflow/strategies/fan-out-strategy.test.d.ts.map +1 -0
- package/dist/src/workflow/strategies/fan-out-strategy.test.js +97 -0
- package/dist/src/workflow/strategies/index.d.ts +4 -0
- package/dist/src/workflow/strategies/index.d.ts.map +1 -0
- package/dist/src/workflow/strategies/index.js +3 -0
- package/dist/src/workflow/strategies/race-strategy.d.ts +19 -0
- package/dist/src/workflow/strategies/race-strategy.d.ts.map +1 -0
- package/dist/src/workflow/strategies/race-strategy.js +92 -0
- package/dist/src/workflow/strategies/race-strategy.test.d.ts +2 -0
- package/dist/src/workflow/strategies/race-strategy.test.d.ts.map +1 -0
- package/dist/src/workflow/strategies/race-strategy.test.js +318 -0
- package/dist/src/workflow/transition-engine.d.ts.map +1 -1
- package/dist/src/workflow/transition-engine.js +12 -0
- package/dist/src/workflow/transition-engine.test.js +92 -0
- package/dist/src/workflow/workflow-registry.d.ts +5 -1
- package/dist/src/workflow/workflow-registry.d.ts.map +1 -1
- package/dist/src/workflow/workflow-registry.js +8 -0
- package/dist/src/workflow/workflow-registry.test.js +54 -0
- package/dist/src/workflow/workflow-types.d.ts +151 -6
- package/dist/src/workflow/workflow-types.d.ts.map +1 -1
- package/dist/src/workflow/workflow-types.js +71 -1
- package/dist/src/workflow/workflow-types.test.js +293 -2
- package/package.json +2 -2
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { evaluateGatesForPhase, activateGatesForPhase, clearGatesForIssue, getApplicableGates, } from './gate-evaluator.js';
|
|
3
|
+
import { InMemoryGateStorage } from '../gate-state.js';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Helpers
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
function makeGateDefinition(overrides = {}) {
|
|
8
|
+
return {
|
|
9
|
+
name: 'test-gate',
|
|
10
|
+
type: 'signal',
|
|
11
|
+
trigger: { source: 'comment', match: 'APPROVE' },
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function makeWorkflow(gates = []) {
|
|
16
|
+
return {
|
|
17
|
+
apiVersion: 'v1.1',
|
|
18
|
+
kind: 'WorkflowDefinition',
|
|
19
|
+
metadata: { name: 'test-workflow' },
|
|
20
|
+
phases: [{ name: 'development', template: 'dev' }],
|
|
21
|
+
transitions: [{ from: 'Backlog', to: 'development' }],
|
|
22
|
+
gates,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// evaluateGatesForPhase
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
describe('evaluateGatesForPhase', () => {
|
|
29
|
+
let storage;
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.useFakeTimers();
|
|
32
|
+
vi.setSystemTime(new Date('2025-06-01T12:00:00Z'));
|
|
33
|
+
storage = new InMemoryGateStorage();
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.useRealTimers();
|
|
37
|
+
});
|
|
38
|
+
it('returns all satisfied when no gates apply', async () => {
|
|
39
|
+
const workflow = makeWorkflow([]);
|
|
40
|
+
const result = await evaluateGatesForPhase({
|
|
41
|
+
issueId: 'issue-1',
|
|
42
|
+
phase: 'development',
|
|
43
|
+
workflow,
|
|
44
|
+
storage,
|
|
45
|
+
});
|
|
46
|
+
expect(result.allSatisfied).toBe(true);
|
|
47
|
+
expect(result.activeGates).toEqual([]);
|
|
48
|
+
expect(result.newlySatisfied).toEqual([]);
|
|
49
|
+
expect(result.timeoutResolutions).toEqual([]);
|
|
50
|
+
expect(result.reason).toContain('No gates apply');
|
|
51
|
+
});
|
|
52
|
+
it('satisfies active signal gate when a matching comment is provided', async () => {
|
|
53
|
+
const gate = makeGateDefinition({
|
|
54
|
+
name: 'approval',
|
|
55
|
+
type: 'signal',
|
|
56
|
+
trigger: { source: 'comment', match: 'APPROVE' },
|
|
57
|
+
appliesTo: ['development'],
|
|
58
|
+
});
|
|
59
|
+
const workflow = makeWorkflow([gate]);
|
|
60
|
+
// Activate the gate first
|
|
61
|
+
await activateGatesForPhase('issue-1', 'development', workflow, storage);
|
|
62
|
+
// Evaluate with a matching comment
|
|
63
|
+
const result = await evaluateGatesForPhase({
|
|
64
|
+
issueId: 'issue-1',
|
|
65
|
+
phase: 'development',
|
|
66
|
+
workflow,
|
|
67
|
+
storage,
|
|
68
|
+
comments: [{ text: 'APPROVE', isBot: false }],
|
|
69
|
+
});
|
|
70
|
+
expect(result.allSatisfied).toBe(true);
|
|
71
|
+
expect(result.newlySatisfied).toHaveLength(1);
|
|
72
|
+
expect(result.newlySatisfied[0].gateName).toBe('approval');
|
|
73
|
+
});
|
|
74
|
+
it('timer gate fires at correct time', async () => {
|
|
75
|
+
const gate = makeGateDefinition({
|
|
76
|
+
name: 'scheduled',
|
|
77
|
+
type: 'timer',
|
|
78
|
+
trigger: { cron: '0 12 * * *' }, // fires at 12:00
|
|
79
|
+
appliesTo: ['development'],
|
|
80
|
+
});
|
|
81
|
+
const workflow = makeWorkflow([gate]);
|
|
82
|
+
// Activate the gate
|
|
83
|
+
await activateGatesForPhase('issue-1', 'development', workflow, storage);
|
|
84
|
+
// Evaluate at 12:00:30 (should fire since current minute matches)
|
|
85
|
+
const now = new Date(2025, 5, 1, 12, 0, 30, 0).getTime();
|
|
86
|
+
const result = await evaluateGatesForPhase({
|
|
87
|
+
issueId: 'issue-1',
|
|
88
|
+
phase: 'development',
|
|
89
|
+
workflow,
|
|
90
|
+
storage,
|
|
91
|
+
now,
|
|
92
|
+
});
|
|
93
|
+
expect(result.allSatisfied).toBe(true);
|
|
94
|
+
expect(result.newlySatisfied).toHaveLength(1);
|
|
95
|
+
expect(result.newlySatisfied[0].gateName).toBe('scheduled');
|
|
96
|
+
});
|
|
97
|
+
it('unsatisfied gates block (allSatisfied is false)', async () => {
|
|
98
|
+
const gate = makeGateDefinition({
|
|
99
|
+
name: 'approval',
|
|
100
|
+
type: 'signal',
|
|
101
|
+
trigger: { source: 'comment', match: 'APPROVE' },
|
|
102
|
+
appliesTo: ['development'],
|
|
103
|
+
});
|
|
104
|
+
const workflow = makeWorkflow([gate]);
|
|
105
|
+
// Activate the gate
|
|
106
|
+
await activateGatesForPhase('issue-1', 'development', workflow, storage);
|
|
107
|
+
// Evaluate without matching comments
|
|
108
|
+
const result = await evaluateGatesForPhase({
|
|
109
|
+
issueId: 'issue-1',
|
|
110
|
+
phase: 'development',
|
|
111
|
+
workflow,
|
|
112
|
+
storage,
|
|
113
|
+
comments: [{ text: 'not a match', isBot: false }],
|
|
114
|
+
});
|
|
115
|
+
expect(result.allSatisfied).toBe(false);
|
|
116
|
+
expect(result.activeGates).toHaveLength(1);
|
|
117
|
+
expect(result.activeGates[0].gateName).toBe('approval');
|
|
118
|
+
expect(result.reason).toContain('Unsatisfied gates');
|
|
119
|
+
});
|
|
120
|
+
it('processes timeouts for active gates', async () => {
|
|
121
|
+
const gate = makeGateDefinition({
|
|
122
|
+
name: 'timed-gate',
|
|
123
|
+
type: 'signal',
|
|
124
|
+
trigger: { source: 'comment', match: 'APPROVE' },
|
|
125
|
+
timeout: { duration: '1h', action: 'fail' },
|
|
126
|
+
appliesTo: ['development'],
|
|
127
|
+
});
|
|
128
|
+
const workflow = makeWorkflow([gate]);
|
|
129
|
+
// Activate the gate
|
|
130
|
+
await activateGatesForPhase('issue-1', 'development', workflow, storage);
|
|
131
|
+
// Advance time past the timeout
|
|
132
|
+
vi.advanceTimersByTime(2 * 60 * 60 * 1000); // 2 hours
|
|
133
|
+
const result = await evaluateGatesForPhase({
|
|
134
|
+
issueId: 'issue-1',
|
|
135
|
+
phase: 'development',
|
|
136
|
+
workflow,
|
|
137
|
+
storage,
|
|
138
|
+
});
|
|
139
|
+
expect(result.timeoutResolutions).toHaveLength(1);
|
|
140
|
+
expect(result.timeoutResolutions[0].type).toBe('fail');
|
|
141
|
+
expect(result.timeoutResolutions[0].gateName).toBe('timed-gate');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// activateGatesForPhase
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
describe('activateGatesForPhase', () => {
|
|
148
|
+
let storage;
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
vi.useFakeTimers();
|
|
151
|
+
vi.setSystemTime(new Date('2025-06-01T12:00:00Z'));
|
|
152
|
+
storage = new InMemoryGateStorage();
|
|
153
|
+
});
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
vi.useRealTimers();
|
|
156
|
+
});
|
|
157
|
+
it('activates applicable gates', async () => {
|
|
158
|
+
const gates = [
|
|
159
|
+
makeGateDefinition({ name: 'gate-1', type: 'signal', appliesTo: ['development'] }),
|
|
160
|
+
makeGateDefinition({ name: 'gate-2', type: 'timer', trigger: { cron: '0 9 * * *' }, appliesTo: ['development'] }),
|
|
161
|
+
];
|
|
162
|
+
const workflow = makeWorkflow(gates);
|
|
163
|
+
const activated = await activateGatesForPhase('issue-1', 'development', workflow, storage);
|
|
164
|
+
expect(activated).toHaveLength(2);
|
|
165
|
+
expect(activated.map(g => g.gateName)).toContain('gate-1');
|
|
166
|
+
expect(activated.map(g => g.gateName)).toContain('gate-2');
|
|
167
|
+
});
|
|
168
|
+
it('skips already-activated gates', async () => {
|
|
169
|
+
const gate = makeGateDefinition({ name: 'gate-1', type: 'signal', appliesTo: ['development'] });
|
|
170
|
+
const workflow = makeWorkflow([gate]);
|
|
171
|
+
// Activate once
|
|
172
|
+
const first = await activateGatesForPhase('issue-1', 'development', workflow, storage);
|
|
173
|
+
expect(first).toHaveLength(1);
|
|
174
|
+
// Activate again — should skip
|
|
175
|
+
const second = await activateGatesForPhase('issue-1', 'development', workflow, storage);
|
|
176
|
+
expect(second).toHaveLength(0);
|
|
177
|
+
});
|
|
178
|
+
it('returns empty array when no gates apply', async () => {
|
|
179
|
+
const workflow = makeWorkflow([]);
|
|
180
|
+
const activated = await activateGatesForPhase('issue-1', 'development', workflow, storage);
|
|
181
|
+
expect(activated).toEqual([]);
|
|
182
|
+
});
|
|
183
|
+
it('does not activate gates for other phases', async () => {
|
|
184
|
+
const gate = makeGateDefinition({ name: 'qa-gate', type: 'signal', appliesTo: ['qa'] });
|
|
185
|
+
const workflow = makeWorkflow([gate]);
|
|
186
|
+
const activated = await activateGatesForPhase('issue-1', 'development', workflow, storage);
|
|
187
|
+
expect(activated).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// clearGatesForIssue
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
describe('clearGatesForIssue', () => {
|
|
194
|
+
it('delegates to storage.clearGateStates', async () => {
|
|
195
|
+
const storage = new InMemoryGateStorage();
|
|
196
|
+
// Add some gates
|
|
197
|
+
await storage.setGateState('issue-1', 'gate-a', {
|
|
198
|
+
issueId: 'issue-1',
|
|
199
|
+
gateName: 'gate-a',
|
|
200
|
+
gateType: 'signal',
|
|
201
|
+
status: 'active',
|
|
202
|
+
activatedAt: Date.now(),
|
|
203
|
+
});
|
|
204
|
+
await clearGatesForIssue('issue-1', storage);
|
|
205
|
+
const result = await storage.getGateState('issue-1', 'gate-a');
|
|
206
|
+
expect(result).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// getApplicableGates
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
describe('getApplicableGates', () => {
|
|
213
|
+
it('aggregates gates across all types', () => {
|
|
214
|
+
const gates = [
|
|
215
|
+
makeGateDefinition({ name: 'signal-1', type: 'signal', appliesTo: ['development'] }),
|
|
216
|
+
makeGateDefinition({ name: 'timer-1', type: 'timer', trigger: { cron: '0 9 * * *' }, appliesTo: ['development'] }),
|
|
217
|
+
makeGateDefinition({ name: 'webhook-1', type: 'webhook', trigger: { endpoint: '/api' }, appliesTo: ['development'] }),
|
|
218
|
+
];
|
|
219
|
+
const workflow = makeWorkflow(gates);
|
|
220
|
+
const result = getApplicableGates(workflow, 'development');
|
|
221
|
+
expect(result).toHaveLength(3);
|
|
222
|
+
expect(result.map(g => g.name)).toContain('signal-1');
|
|
223
|
+
expect(result.map(g => g.name)).toContain('timer-1');
|
|
224
|
+
expect(result.map(g => g.name)).toContain('webhook-1');
|
|
225
|
+
});
|
|
226
|
+
it('returns empty array when no gates match phase', () => {
|
|
227
|
+
const gates = [
|
|
228
|
+
makeGateDefinition({ name: 'qa-gate', type: 'signal', appliesTo: ['qa'] }),
|
|
229
|
+
];
|
|
230
|
+
const workflow = makeWorkflow(gates);
|
|
231
|
+
const result = getApplicableGates(workflow, 'development');
|
|
232
|
+
expect(result).toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
it('returns empty array when workflow has no gates', () => {
|
|
235
|
+
const workflow = makeWorkflow();
|
|
236
|
+
delete workflow.gates;
|
|
237
|
+
const result = getApplicableGates(workflow, 'development');
|
|
238
|
+
expect(result).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal Gate Executor
|
|
3
|
+
*
|
|
4
|
+
* Evaluates whether comments or directives satisfy a signal gate.
|
|
5
|
+
* Signal gates pause workflow execution until a matching comment or
|
|
6
|
+
* directive is detected. This module is backward compatible with
|
|
7
|
+
* existing HOLD/RESUME directives from the override-parser system.
|
|
8
|
+
*
|
|
9
|
+
* All evaluator functions are pure (no I/O) — they take inputs and
|
|
10
|
+
* return results without side effects.
|
|
11
|
+
*/
|
|
12
|
+
import type { GateDefinition, WorkflowDefinition } from '../workflow-types.js';
|
|
13
|
+
/**
|
|
14
|
+
* Trigger configuration for a signal gate.
|
|
15
|
+
* Defines how incoming comments are matched against the gate condition.
|
|
16
|
+
*/
|
|
17
|
+
export interface SignalGateTrigger {
|
|
18
|
+
/** Whether to match against the full comment text or the first-line directive only */
|
|
19
|
+
source: 'comment' | 'directive';
|
|
20
|
+
/** String to match — can be an exact string or a regex pattern */
|
|
21
|
+
match: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Result of evaluating a comment against a signal gate
|
|
25
|
+
*/
|
|
26
|
+
export interface SignalGateResult {
|
|
27
|
+
/** Whether the comment matched the signal gate trigger */
|
|
28
|
+
matched: boolean;
|
|
29
|
+
/** The content that matched (the full comment or directive line) */
|
|
30
|
+
source?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Type guard to validate that a trigger object has the correct shape
|
|
34
|
+
* for a signal gate trigger.
|
|
35
|
+
*
|
|
36
|
+
* A valid SignalGateTrigger must have:
|
|
37
|
+
* - `source`: either 'comment' or 'directive'
|
|
38
|
+
* - `match`: a non-empty string
|
|
39
|
+
*
|
|
40
|
+
* @param trigger - The trigger record to validate
|
|
41
|
+
* @returns True if the trigger is a valid SignalGateTrigger
|
|
42
|
+
*/
|
|
43
|
+
export declare function isSignalGateTrigger(trigger: Record<string, unknown>): trigger is Record<string, unknown> & SignalGateTrigger;
|
|
44
|
+
/**
|
|
45
|
+
* Evaluate whether a comment satisfies a signal gate trigger.
|
|
46
|
+
*
|
|
47
|
+
* This is a pure function (no I/O) that checks if a comment matches
|
|
48
|
+
* the signal gate's trigger configuration.
|
|
49
|
+
*
|
|
50
|
+
* Matching rules:
|
|
51
|
+
* - Bot comments are always skipped (returns { matched: false })
|
|
52
|
+
* - When `trigger.source` is 'directive', only the first non-empty line
|
|
53
|
+
* of the comment is checked (consistent with override-parser.ts)
|
|
54
|
+
* - When `trigger.source` is 'comment', the full trimmed comment text is checked
|
|
55
|
+
* - The `trigger.match` string is first tried as an exact case-insensitive match,
|
|
56
|
+
* then as a regex pattern (case-insensitive)
|
|
57
|
+
*
|
|
58
|
+
* @param gate - The gate definition containing a signal trigger
|
|
59
|
+
* @param comment - The full comment body text
|
|
60
|
+
* @param isBot - Whether the comment was authored by a bot
|
|
61
|
+
* @returns A SignalGateResult indicating whether the comment matched
|
|
62
|
+
*/
|
|
63
|
+
export declare function evaluateSignalGate(gate: GateDefinition, comment: string, isBot: boolean): SignalGateResult;
|
|
64
|
+
/**
|
|
65
|
+
* Get all signal gates from a workflow definition that apply to a given phase.
|
|
66
|
+
*
|
|
67
|
+
* Filters gates by:
|
|
68
|
+
* 1. `type === 'signal'`
|
|
69
|
+
* 2. `appliesTo` includes the given phase name, OR `appliesTo` is not defined
|
|
70
|
+
* (gate applies to all phases)
|
|
71
|
+
*
|
|
72
|
+
* @param workflow - The workflow definition containing gate configurations
|
|
73
|
+
* @param phase - The phase name to filter by
|
|
74
|
+
* @returns Array of GateDefinition objects that are signal gates applicable to the phase
|
|
75
|
+
*/
|
|
76
|
+
export declare function getApplicableSignalGates(workflow: WorkflowDefinition, phase: string): GateDefinition[];
|
|
77
|
+
/**
|
|
78
|
+
* Name used for the implicit HOLD gate created for backward compatibility
|
|
79
|
+
*/
|
|
80
|
+
export declare const IMPLICIT_HOLD_GATE_NAME = "__implicit-hold";
|
|
81
|
+
/**
|
|
82
|
+
* Name used for the implicit RESUME gate created for backward compatibility
|
|
83
|
+
*/
|
|
84
|
+
export declare const IMPLICIT_RESUME_GATE_NAME = "__implicit-resume";
|
|
85
|
+
/**
|
|
86
|
+
* Create an implicit signal gate definition that matches the HOLD directive.
|
|
87
|
+
*
|
|
88
|
+
* This provides backward compatibility with the existing HOLD/RESUME system.
|
|
89
|
+
* When a HOLD directive is detected and no explicit signal gate is defined,
|
|
90
|
+
* this creates a gate that will pause the workflow until a RESUME directive
|
|
91
|
+
* is received.
|
|
92
|
+
*
|
|
93
|
+
* The created gate matches:
|
|
94
|
+
* - Directive source: first line of comment
|
|
95
|
+
* - Pattern: `^hold(?:\s*[---]\s*(.+))?$` (matches HOLD or HOLD -- reason)
|
|
96
|
+
*
|
|
97
|
+
* @returns A GateDefinition representing the implicit HOLD gate
|
|
98
|
+
*/
|
|
99
|
+
export declare function createImplicitHoldGate(): GateDefinition;
|
|
100
|
+
/**
|
|
101
|
+
* Create an implicit signal gate definition that matches the RESUME directive.
|
|
102
|
+
*
|
|
103
|
+
* This is the counterpart to the implicit HOLD gate. When a workflow is
|
|
104
|
+
* paused by a HOLD directive, this gate defines the condition that will
|
|
105
|
+
* release the hold — receiving a RESUME directive.
|
|
106
|
+
*
|
|
107
|
+
* The created gate matches:
|
|
108
|
+
* - Directive source: first line of comment
|
|
109
|
+
* - Pattern: `^resume$` (exact match for RESUME directive)
|
|
110
|
+
*
|
|
111
|
+
* @returns A GateDefinition representing the implicit RESUME gate
|
|
112
|
+
*/
|
|
113
|
+
export declare function createImplicitResumeGate(): GateDefinition;
|
|
114
|
+
//# sourceMappingURL=signal-gate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"signal-gate.d.ts","sourceRoot":"","sources":["../../../../src/workflow/gates/signal-gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAA;AAa9E;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,sFAAsF;IACtF,MAAM,EAAE,SAAS,GAAG,WAAW,CAAA;IAC/B,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAA;CACd;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,0DAA0D;IAC1D,OAAO,EAAE,OAAO,CAAA;IAChB,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAMD;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,iBAAiB,CAM5H;AAwBD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,gBAAgB,CAgD1G;AAMD;;;;;;;;;;;GAWG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,kBAAkB,EAAE,KAAK,EAAE,MAAM,GAAG,cAAc,EAAE,CAiBtG;AAMD;;GAEG;AACH,eAAO,MAAM,uBAAuB,oBAAoB,CAAA;AAExD;;GAEG;AACH,eAAO,MAAM,yBAAyB,sBAAsB,CAAA;AAE5D;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,IAAI,cAAc,CAUvD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,wBAAwB,IAAI,cAAc,CAUzD"}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal Gate Executor
|
|
3
|
+
*
|
|
4
|
+
* Evaluates whether comments or directives satisfy a signal gate.
|
|
5
|
+
* Signal gates pause workflow execution until a matching comment or
|
|
6
|
+
* directive is detected. This module is backward compatible with
|
|
7
|
+
* existing HOLD/RESUME directives from the override-parser system.
|
|
8
|
+
*
|
|
9
|
+
* All evaluator functions are pure (no I/O) — they take inputs and
|
|
10
|
+
* return results without side effects.
|
|
11
|
+
*/
|
|
12
|
+
const log = {
|
|
13
|
+
info: (msg, data) => console.log(`[signal-gate] ${msg}`, data ? JSON.stringify(data) : ''),
|
|
14
|
+
warn: (msg, data) => console.warn(`[signal-gate] ${msg}`, data ? JSON.stringify(data) : ''),
|
|
15
|
+
error: (msg, data) => console.error(`[signal-gate] ${msg}`, data ? JSON.stringify(data) : ''),
|
|
16
|
+
debug: (_msg, _data) => { },
|
|
17
|
+
};
|
|
18
|
+
// ============================================
|
|
19
|
+
// Type Guards
|
|
20
|
+
// ============================================
|
|
21
|
+
/**
|
|
22
|
+
* Type guard to validate that a trigger object has the correct shape
|
|
23
|
+
* for a signal gate trigger.
|
|
24
|
+
*
|
|
25
|
+
* A valid SignalGateTrigger must have:
|
|
26
|
+
* - `source`: either 'comment' or 'directive'
|
|
27
|
+
* - `match`: a non-empty string
|
|
28
|
+
*
|
|
29
|
+
* @param trigger - The trigger record to validate
|
|
30
|
+
* @returns True if the trigger is a valid SignalGateTrigger
|
|
31
|
+
*/
|
|
32
|
+
export function isSignalGateTrigger(trigger) {
|
|
33
|
+
if (typeof trigger.source !== 'string')
|
|
34
|
+
return false;
|
|
35
|
+
if (trigger.source !== 'comment' && trigger.source !== 'directive')
|
|
36
|
+
return false;
|
|
37
|
+
if (typeof trigger.match !== 'string')
|
|
38
|
+
return false;
|
|
39
|
+
if (trigger.match.length === 0)
|
|
40
|
+
return false;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
// ============================================
|
|
44
|
+
// Signal Gate Evaluator
|
|
45
|
+
// ============================================
|
|
46
|
+
/**
|
|
47
|
+
* Extract the first non-empty line from a comment body.
|
|
48
|
+
* Mirrors the directive extraction logic in override-parser.ts.
|
|
49
|
+
*
|
|
50
|
+
* @param body - The full comment body text
|
|
51
|
+
* @returns The trimmed first non-empty line, or empty string if none
|
|
52
|
+
*/
|
|
53
|
+
function extractDirectiveLine(body) {
|
|
54
|
+
const lines = body.split('\n');
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
const trimmed = line.trim();
|
|
57
|
+
if (trimmed.length > 0) {
|
|
58
|
+
return trimmed;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Evaluate whether a comment satisfies a signal gate trigger.
|
|
65
|
+
*
|
|
66
|
+
* This is a pure function (no I/O) that checks if a comment matches
|
|
67
|
+
* the signal gate's trigger configuration.
|
|
68
|
+
*
|
|
69
|
+
* Matching rules:
|
|
70
|
+
* - Bot comments are always skipped (returns { matched: false })
|
|
71
|
+
* - When `trigger.source` is 'directive', only the first non-empty line
|
|
72
|
+
* of the comment is checked (consistent with override-parser.ts)
|
|
73
|
+
* - When `trigger.source` is 'comment', the full trimmed comment text is checked
|
|
74
|
+
* - The `trigger.match` string is first tried as an exact case-insensitive match,
|
|
75
|
+
* then as a regex pattern (case-insensitive)
|
|
76
|
+
*
|
|
77
|
+
* @param gate - The gate definition containing a signal trigger
|
|
78
|
+
* @param comment - The full comment body text
|
|
79
|
+
* @param isBot - Whether the comment was authored by a bot
|
|
80
|
+
* @returns A SignalGateResult indicating whether the comment matched
|
|
81
|
+
*/
|
|
82
|
+
export function evaluateSignalGate(gate, comment, isBot) {
|
|
83
|
+
// Bot comments are always ignored
|
|
84
|
+
if (isBot) {
|
|
85
|
+
log.debug('Skipping bot comment for signal gate', { gateName: gate.name });
|
|
86
|
+
return { matched: false };
|
|
87
|
+
}
|
|
88
|
+
// Validate trigger shape
|
|
89
|
+
if (!isSignalGateTrigger(gate.trigger)) {
|
|
90
|
+
log.warn('Gate has invalid signal trigger configuration', { gateName: gate.name, trigger: gate.trigger });
|
|
91
|
+
return { matched: false };
|
|
92
|
+
}
|
|
93
|
+
const trigger = gate.trigger;
|
|
94
|
+
// Determine the text to match against based on trigger source
|
|
95
|
+
let textToMatch;
|
|
96
|
+
if (trigger.source === 'directive') {
|
|
97
|
+
textToMatch = extractDirectiveLine(comment);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
textToMatch = comment.trim();
|
|
101
|
+
}
|
|
102
|
+
// Empty text cannot match
|
|
103
|
+
if (textToMatch.length === 0) {
|
|
104
|
+
return { matched: false };
|
|
105
|
+
}
|
|
106
|
+
// Try exact case-insensitive match first
|
|
107
|
+
if (textToMatch.toLowerCase() === trigger.match.toLowerCase()) {
|
|
108
|
+
log.debug('Signal gate matched (exact)', { gateName: gate.name, source: textToMatch });
|
|
109
|
+
return { matched: true, source: textToMatch };
|
|
110
|
+
}
|
|
111
|
+
// Try regex match (case-insensitive)
|
|
112
|
+
try {
|
|
113
|
+
const regex = new RegExp(trigger.match, 'i');
|
|
114
|
+
const match = textToMatch.match(regex);
|
|
115
|
+
if (match) {
|
|
116
|
+
log.debug('Signal gate matched (regex)', { gateName: gate.name, source: textToMatch });
|
|
117
|
+
return { matched: true, source: textToMatch };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Invalid regex pattern — treat as exact match only (already tried above)
|
|
122
|
+
log.warn('Invalid regex in signal gate trigger match', { gateName: gate.name, match: trigger.match });
|
|
123
|
+
}
|
|
124
|
+
return { matched: false };
|
|
125
|
+
}
|
|
126
|
+
// ============================================
|
|
127
|
+
// Workflow Query Helpers
|
|
128
|
+
// ============================================
|
|
129
|
+
/**
|
|
130
|
+
* Get all signal gates from a workflow definition that apply to a given phase.
|
|
131
|
+
*
|
|
132
|
+
* Filters gates by:
|
|
133
|
+
* 1. `type === 'signal'`
|
|
134
|
+
* 2. `appliesTo` includes the given phase name, OR `appliesTo` is not defined
|
|
135
|
+
* (gate applies to all phases)
|
|
136
|
+
*
|
|
137
|
+
* @param workflow - The workflow definition containing gate configurations
|
|
138
|
+
* @param phase - The phase name to filter by
|
|
139
|
+
* @returns Array of GateDefinition objects that are signal gates applicable to the phase
|
|
140
|
+
*/
|
|
141
|
+
export function getApplicableSignalGates(workflow, phase) {
|
|
142
|
+
if (!workflow.gates || workflow.gates.length === 0) {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
return workflow.gates.filter((gate) => {
|
|
146
|
+
// Must be a signal gate
|
|
147
|
+
if (gate.type !== 'signal')
|
|
148
|
+
return false;
|
|
149
|
+
// If appliesTo is defined, the phase must be in the list
|
|
150
|
+
if (gate.appliesTo && gate.appliesTo.length > 0) {
|
|
151
|
+
return gate.appliesTo.includes(phase);
|
|
152
|
+
}
|
|
153
|
+
// No appliesTo restriction — applies to all phases
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// ============================================
|
|
158
|
+
// HOLD/RESUME Backward Compatibility
|
|
159
|
+
// ============================================
|
|
160
|
+
/**
|
|
161
|
+
* Name used for the implicit HOLD gate created for backward compatibility
|
|
162
|
+
*/
|
|
163
|
+
export const IMPLICIT_HOLD_GATE_NAME = '__implicit-hold';
|
|
164
|
+
/**
|
|
165
|
+
* Name used for the implicit RESUME gate created for backward compatibility
|
|
166
|
+
*/
|
|
167
|
+
export const IMPLICIT_RESUME_GATE_NAME = '__implicit-resume';
|
|
168
|
+
/**
|
|
169
|
+
* Create an implicit signal gate definition that matches the HOLD directive.
|
|
170
|
+
*
|
|
171
|
+
* This provides backward compatibility with the existing HOLD/RESUME system.
|
|
172
|
+
* When a HOLD directive is detected and no explicit signal gate is defined,
|
|
173
|
+
* this creates a gate that will pause the workflow until a RESUME directive
|
|
174
|
+
* is received.
|
|
175
|
+
*
|
|
176
|
+
* The created gate matches:
|
|
177
|
+
* - Directive source: first line of comment
|
|
178
|
+
* - Pattern: `^hold(?:\s*[---]\s*(.+))?$` (matches HOLD or HOLD -- reason)
|
|
179
|
+
*
|
|
180
|
+
* @returns A GateDefinition representing the implicit HOLD gate
|
|
181
|
+
*/
|
|
182
|
+
export function createImplicitHoldGate() {
|
|
183
|
+
return {
|
|
184
|
+
name: IMPLICIT_HOLD_GATE_NAME,
|
|
185
|
+
description: 'Implicit gate created for backward-compatible HOLD directive',
|
|
186
|
+
type: 'signal',
|
|
187
|
+
trigger: {
|
|
188
|
+
source: 'directive',
|
|
189
|
+
match: '^hold(?:\\s*[\\u2014\\u2013-]\\s*(.+))?$',
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Create an implicit signal gate definition that matches the RESUME directive.
|
|
195
|
+
*
|
|
196
|
+
* This is the counterpart to the implicit HOLD gate. When a workflow is
|
|
197
|
+
* paused by a HOLD directive, this gate defines the condition that will
|
|
198
|
+
* release the hold — receiving a RESUME directive.
|
|
199
|
+
*
|
|
200
|
+
* The created gate matches:
|
|
201
|
+
* - Directive source: first line of comment
|
|
202
|
+
* - Pattern: `^resume$` (exact match for RESUME directive)
|
|
203
|
+
*
|
|
204
|
+
* @returns A GateDefinition representing the implicit RESUME gate
|
|
205
|
+
*/
|
|
206
|
+
export function createImplicitResumeGate() {
|
|
207
|
+
return {
|
|
208
|
+
name: IMPLICIT_RESUME_GATE_NAME,
|
|
209
|
+
description: 'Implicit gate created for backward-compatible RESUME directive',
|
|
210
|
+
type: 'signal',
|
|
211
|
+
trigger: {
|
|
212
|
+
source: 'directive',
|
|
213
|
+
match: '^resume$',
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"signal-gate.test.d.ts","sourceRoot":"","sources":["../../../../src/workflow/gates/signal-gate.test.ts"],"names":[],"mappings":""}
|