omnikey-cli 1.2.0 → 1.4.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/backend-dist/__tests__/ai-client.adapters.test.js +247 -0
- package/backend-dist/__tests__/ai-client.helpers.test.js +99 -0
- package/backend-dist/__tests__/featureRoutes.runEnhancementModel.test.js +79 -0
- package/backend-dist/ai-client.js +54 -6
- package/backend-dist/featureRoutes.js +13 -7
- package/backend-dist/index.js +25 -7
- package/backend-dist/scheduledJobRoutes.js +14 -3
- package/backend-dist/workers/groupingWorker.js +7 -0
- package/backend-dist/workers/scheduledJobWorker.js +30 -0
- package/backend-dist/workers/scheduledJobWorkerClient.js +25 -0
- package/backend-dist/workers/spawn.js +115 -0
- package/backend-dist/workers/workerBootstrap.js +63 -0
- package/package.json +1 -1
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Per-provider adapter tests for temperature handling.
|
|
4
|
+
*
|
|
5
|
+
* The three SDKs (`openai`, `@anthropic-ai/sdk`, `@google/genai`) are mocked
|
|
6
|
+
* at the module boundary using `vi.mock`. Mock spies are declared inside a
|
|
7
|
+
* `vi.hoisted()` block so they are available when `vi.mock` factories run
|
|
8
|
+
* (vi.mock is hoisted to the top of the file).
|
|
9
|
+
*
|
|
10
|
+
* These tests never contact any real API.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
const vitest_1 = require("vitest");
|
|
14
|
+
const mocks = vitest_1.vi.hoisted(() => ({
|
|
15
|
+
openaiCreate: vitest_1.vi.fn(),
|
|
16
|
+
anthropicCreate: vitest_1.vi.fn(),
|
|
17
|
+
anthropicStream: vitest_1.vi.fn(),
|
|
18
|
+
geminiGenerate: vitest_1.vi.fn(),
|
|
19
|
+
geminiGenerateStream: vitest_1.vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
vitest_1.vi.mock('openai', () => ({
|
|
22
|
+
default: class MockOpenAI {
|
|
23
|
+
constructor(_opts) {
|
|
24
|
+
this.chat = { completions: { create: mocks.openaiCreate } };
|
|
25
|
+
this.images = { generate: vitest_1.vi.fn() };
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
vitest_1.vi.mock('@anthropic-ai/sdk', () => ({
|
|
30
|
+
default: class MockAnthropic {
|
|
31
|
+
constructor(_opts) {
|
|
32
|
+
this.messages = { create: mocks.anthropicCreate, stream: mocks.anthropicStream };
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
vitest_1.vi.mock('@google/genai', () => ({
|
|
37
|
+
GoogleGenAI: class MockGoogleGenAI {
|
|
38
|
+
constructor(_opts) {
|
|
39
|
+
this.models = {
|
|
40
|
+
generateContent: mocks.geminiGenerate,
|
|
41
|
+
generateContentStream: mocks.geminiGenerateStream,
|
|
42
|
+
generateImages: vitest_1.vi.fn(),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
// The adapter file imports these as types-only but they still need to resolve.
|
|
47
|
+
Content: class {
|
|
48
|
+
},
|
|
49
|
+
Tool: class {
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
const ai_client_1 = require("../ai-client");
|
|
53
|
+
const messages = [{ role: 'user', content: 'hello' }];
|
|
54
|
+
function asAsyncIterable(chunks) {
|
|
55
|
+
return {
|
|
56
|
+
[Symbol.asyncIterator]: async function* () {
|
|
57
|
+
for (const c of chunks)
|
|
58
|
+
yield c;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
(0, vitest_1.beforeEach)(() => {
|
|
63
|
+
mocks.openaiCreate.mockReset();
|
|
64
|
+
mocks.anthropicCreate.mockReset();
|
|
65
|
+
mocks.anthropicStream.mockReset();
|
|
66
|
+
mocks.geminiGenerate.mockReset();
|
|
67
|
+
mocks.geminiGenerateStream.mockReset();
|
|
68
|
+
});
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// OpenAI
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
(0, vitest_1.describe)('OpenAIAdapter temperature handling', () => {
|
|
73
|
+
function mockCompleteResponse() {
|
|
74
|
+
mocks.openaiCreate.mockResolvedValueOnce({
|
|
75
|
+
choices: [{ message: { content: 'ok', tool_calls: undefined }, finish_reason: 'stop' }],
|
|
76
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function mockStreamResponse() {
|
|
80
|
+
mocks.openaiCreate.mockResolvedValueOnce(asAsyncIterable([
|
|
81
|
+
{ choices: [{ delta: { content: 'ok' } }] },
|
|
82
|
+
{
|
|
83
|
+
choices: [{ delta: {} }],
|
|
84
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
85
|
+
},
|
|
86
|
+
]));
|
|
87
|
+
}
|
|
88
|
+
(0, vitest_1.it)('complete: passes temperature for gpt-4o-mini', async () => {
|
|
89
|
+
mockCompleteResponse();
|
|
90
|
+
const client = new ai_client_1.AIClient('openai', 'sk-test');
|
|
91
|
+
await client.complete('gpt-4o-mini', messages, { temperature: 0.42 });
|
|
92
|
+
const body = mocks.openaiCreate.mock.calls[0][0];
|
|
93
|
+
(0, vitest_1.expect)(body).toHaveProperty('temperature', 0.42);
|
|
94
|
+
});
|
|
95
|
+
(0, vitest_1.it)('complete: omits temperature for gpt-5.5 even if caller supplies one', async () => {
|
|
96
|
+
mockCompleteResponse();
|
|
97
|
+
const client = new ai_client_1.AIClient('openai', 'sk-test');
|
|
98
|
+
await client.complete('gpt-5.5', messages, { temperature: 0.42 });
|
|
99
|
+
const body = mocks.openaiCreate.mock.calls[0][0];
|
|
100
|
+
(0, vitest_1.expect)(body).not.toHaveProperty('temperature');
|
|
101
|
+
});
|
|
102
|
+
vitest_1.it.each(['gpt-5', 'gpt-5-mini', 'gpt-5.1', 'o1', 'o3-mini', 'o4-mini'])('complete: omits temperature for unsupported model %s', async (model) => {
|
|
103
|
+
mockCompleteResponse();
|
|
104
|
+
const client = new ai_client_1.AIClient('openai', 'sk-test');
|
|
105
|
+
await client.complete(model, messages, { temperature: 0.7 });
|
|
106
|
+
const body = mocks.openaiCreate.mock.calls[0][0];
|
|
107
|
+
(0, vitest_1.expect)(body).not.toHaveProperty('temperature');
|
|
108
|
+
});
|
|
109
|
+
(0, vitest_1.it)('streamComplete: passes temperature for gpt-4o-mini', async () => {
|
|
110
|
+
mockStreamResponse();
|
|
111
|
+
const client = new ai_client_1.AIClient('openai', 'sk-test');
|
|
112
|
+
await client.streamComplete('gpt-4o-mini', messages, { temperature: 0.31 }, () => { });
|
|
113
|
+
const body = mocks.openaiCreate.mock.calls[0][0];
|
|
114
|
+
(0, vitest_1.expect)(body).toHaveProperty('temperature', 0.31);
|
|
115
|
+
(0, vitest_1.expect)(body).toHaveProperty('stream', true);
|
|
116
|
+
});
|
|
117
|
+
(0, vitest_1.it)('streamComplete: omits temperature for gpt-5.5', async () => {
|
|
118
|
+
mockStreamResponse();
|
|
119
|
+
const client = new ai_client_1.AIClient('openai', 'sk-test');
|
|
120
|
+
await client.streamComplete('gpt-5.5', messages, { temperature: 0.31 }, () => { });
|
|
121
|
+
const body = mocks.openaiCreate.mock.calls[0][0];
|
|
122
|
+
(0, vitest_1.expect)(body).not.toHaveProperty('temperature');
|
|
123
|
+
(0, vitest_1.expect)(body).toHaveProperty('stream', true);
|
|
124
|
+
});
|
|
125
|
+
(0, vitest_1.it)('streamComplete: omits temperature even when caller passes empty options for gpt-5.5', async () => {
|
|
126
|
+
mockStreamResponse();
|
|
127
|
+
const client = new ai_client_1.AIClient('openai', 'sk-test');
|
|
128
|
+
await client.streamComplete('gpt-5.5', messages, {}, () => { });
|
|
129
|
+
const body = mocks.openaiCreate.mock.calls[0][0];
|
|
130
|
+
(0, vitest_1.expect)(body).not.toHaveProperty('temperature');
|
|
131
|
+
});
|
|
132
|
+
(0, vitest_1.it)('streamComplete: uses 0.3 default for supported model when caller omits temperature', async () => {
|
|
133
|
+
mockStreamResponse();
|
|
134
|
+
const client = new ai_client_1.AIClient('openai', 'sk-test');
|
|
135
|
+
await client.streamComplete('gpt-4o-mini', messages, {}, () => { });
|
|
136
|
+
const body = mocks.openaiCreate.mock.calls[0][0];
|
|
137
|
+
(0, vitest_1.expect)(body).toHaveProperty('temperature', 0.3);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Anthropic
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
(0, vitest_1.describe)('AnthropicAdapter temperature handling', () => {
|
|
144
|
+
function mockCompleteResponse() {
|
|
145
|
+
mocks.anthropicCreate.mockResolvedValueOnce({
|
|
146
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
147
|
+
stop_reason: 'end_turn',
|
|
148
|
+
usage: { input_tokens: 1, output_tokens: 1 },
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
function mockStreamResponse() {
|
|
152
|
+
const finalMessage = vitest_1.vi.fn().mockResolvedValue({
|
|
153
|
+
usage: { input_tokens: 1, output_tokens: 1 },
|
|
154
|
+
});
|
|
155
|
+
const stream = asAsyncIterable([
|
|
156
|
+
{ type: 'content_block_delta', delta: { type: 'text_delta', text: 'ok' } },
|
|
157
|
+
]);
|
|
158
|
+
stream.finalMessage = finalMessage;
|
|
159
|
+
mocks.anthropicStream.mockReturnValueOnce(stream);
|
|
160
|
+
}
|
|
161
|
+
(0, vitest_1.it)('complete: passes temperature for claude-sonnet-4-5', async () => {
|
|
162
|
+
mockCompleteResponse();
|
|
163
|
+
const client = new ai_client_1.AIClient('anthropic', 'sk-anthropic-test');
|
|
164
|
+
await client.complete('claude-sonnet-4-5', messages, { temperature: 0.42 });
|
|
165
|
+
const body = mocks.anthropicCreate.mock.calls[0][0];
|
|
166
|
+
(0, vitest_1.expect)(body).toHaveProperty('temperature', 0.42);
|
|
167
|
+
});
|
|
168
|
+
vitest_1.it.each([
|
|
169
|
+
'claude-haiku-4-5-20251001',
|
|
170
|
+
'claude-opus-4-5',
|
|
171
|
+
'claude-opus-4-5-20251101',
|
|
172
|
+
'claude-opus-4-6',
|
|
173
|
+
])('complete: passes temperature for supported model %s', async (model) => {
|
|
174
|
+
mockCompleteResponse();
|
|
175
|
+
const client = new ai_client_1.AIClient('anthropic', 'sk-anthropic-test');
|
|
176
|
+
await client.complete(model, messages, { temperature: 0.5 });
|
|
177
|
+
const body = mocks.anthropicCreate.mock.calls[0][0];
|
|
178
|
+
(0, vitest_1.expect)(body).toHaveProperty('temperature', 0.5);
|
|
179
|
+
});
|
|
180
|
+
vitest_1.it.each(['claude-opus-4-7', 'claude-opus-4-7-20260101'])('complete: omits temperature for unsupported model %s', async (model) => {
|
|
181
|
+
mockCompleteResponse();
|
|
182
|
+
const client = new ai_client_1.AIClient('anthropic', 'sk-anthropic-test');
|
|
183
|
+
await client.complete(model, messages, { temperature: 0.5 });
|
|
184
|
+
const body = mocks.anthropicCreate.mock.calls[0][0];
|
|
185
|
+
(0, vitest_1.expect)(body).not.toHaveProperty('temperature');
|
|
186
|
+
});
|
|
187
|
+
(0, vitest_1.it)('streamComplete: passes temperature for claude-sonnet-4-5', async () => {
|
|
188
|
+
mockStreamResponse();
|
|
189
|
+
const client = new ai_client_1.AIClient('anthropic', 'sk-anthropic-test');
|
|
190
|
+
await client.streamComplete('claude-sonnet-4-5', messages, { temperature: 0.6 }, () => { });
|
|
191
|
+
const body = mocks.anthropicStream.mock.calls[0][0];
|
|
192
|
+
(0, vitest_1.expect)(body).toHaveProperty('temperature', 0.6);
|
|
193
|
+
});
|
|
194
|
+
(0, vitest_1.it)('streamComplete: omits temperature for claude-opus-4-7', async () => {
|
|
195
|
+
mockStreamResponse();
|
|
196
|
+
const client = new ai_client_1.AIClient('anthropic', 'sk-anthropic-test');
|
|
197
|
+
await client.streamComplete('claude-opus-4-7', messages, { temperature: 0.6 }, () => { });
|
|
198
|
+
const body = mocks.anthropicStream.mock.calls[0][0];
|
|
199
|
+
(0, vitest_1.expect)(body).not.toHaveProperty('temperature');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Gemini
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
(0, vitest_1.describe)('GeminiAdapter temperature handling', () => {
|
|
206
|
+
function mockCompleteResponse() {
|
|
207
|
+
mocks.geminiGenerate.mockResolvedValueOnce({
|
|
208
|
+
candidates: [
|
|
209
|
+
{
|
|
210
|
+
content: { parts: [{ text: 'ok' }] },
|
|
211
|
+
finishReason: 'STOP',
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
function mockStreamResponse() {
|
|
218
|
+
mocks.geminiGenerateStream.mockResolvedValueOnce(asAsyncIterable([
|
|
219
|
+
{ text: 'ok' },
|
|
220
|
+
{
|
|
221
|
+
text: '',
|
|
222
|
+
usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 },
|
|
223
|
+
},
|
|
224
|
+
]));
|
|
225
|
+
}
|
|
226
|
+
vitest_1.it.each(['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-pro'])('complete: passes temperature for %s (all Gemini models accept it)', async (model) => {
|
|
227
|
+
mockCompleteResponse();
|
|
228
|
+
const client = new ai_client_1.AIClient('gemini', 'gemini-test-key');
|
|
229
|
+
await client.complete(model, messages, { temperature: 0.42 });
|
|
230
|
+
const body = mocks.geminiGenerate.mock.calls[0][0];
|
|
231
|
+
(0, vitest_1.expect)(body.config).toHaveProperty('temperature', 0.42);
|
|
232
|
+
});
|
|
233
|
+
(0, vitest_1.it)('streamComplete: passes temperature for gemini-2.5-pro', async () => {
|
|
234
|
+
mockStreamResponse();
|
|
235
|
+
const client = new ai_client_1.AIClient('gemini', 'gemini-test-key');
|
|
236
|
+
await client.streamComplete('gemini-2.5-pro', messages, { temperature: 0.31 }, () => { });
|
|
237
|
+
const body = mocks.geminiGenerateStream.mock.calls[0][0];
|
|
238
|
+
(0, vitest_1.expect)(body.config).toHaveProperty('temperature', 0.31);
|
|
239
|
+
});
|
|
240
|
+
(0, vitest_1.it)('streamComplete: applies default 0.3 when caller omits temperature', async () => {
|
|
241
|
+
mockStreamResponse();
|
|
242
|
+
const client = new ai_client_1.AIClient('gemini', 'gemini-test-key');
|
|
243
|
+
await client.streamComplete('gemini-2.5-pro', messages, {}, () => { });
|
|
244
|
+
const body = mocks.geminiGenerateStream.mock.calls[0][0];
|
|
245
|
+
(0, vitest_1.expect)(body.config).toHaveProperty('temperature', 0.3);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const ai_client_1 = require("../ai-client");
|
|
5
|
+
(0, vitest_1.describe)('modelSupportsTemperature', () => {
|
|
6
|
+
(0, vitest_1.describe)('OpenAI', () => {
|
|
7
|
+
vitest_1.it.each([
|
|
8
|
+
['gpt-4o-mini', true],
|
|
9
|
+
['gpt-4o', true],
|
|
10
|
+
['gpt-4-turbo', true],
|
|
11
|
+
['gpt-3.5-turbo', true],
|
|
12
|
+
])('allows temperature for %s', (model, expected) => {
|
|
13
|
+
(0, vitest_1.expect)((0, ai_client_1.modelSupportsTemperature)(model)).toBe(expected);
|
|
14
|
+
});
|
|
15
|
+
vitest_1.it.each([
|
|
16
|
+
['gpt-5', false],
|
|
17
|
+
['gpt-5-mini', false],
|
|
18
|
+
['gpt-5.1', false],
|
|
19
|
+
['gpt-5.5', false],
|
|
20
|
+
['GPT-5.5', false], // case-insensitive
|
|
21
|
+
])('rejects temperature for GPT-5 family member %s', (model, expected) => {
|
|
22
|
+
(0, vitest_1.expect)((0, ai_client_1.modelSupportsTemperature)(model)).toBe(expected);
|
|
23
|
+
});
|
|
24
|
+
vitest_1.it.each([
|
|
25
|
+
['o1', false],
|
|
26
|
+
['o1-preview', false],
|
|
27
|
+
['o3', false],
|
|
28
|
+
['o3-mini', false],
|
|
29
|
+
['o4-mini', false],
|
|
30
|
+
])('rejects temperature for reasoning model %s', (model, expected) => {
|
|
31
|
+
(0, vitest_1.expect)((0, ai_client_1.modelSupportsTemperature)(model)).toBe(expected);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
(0, vitest_1.describe)('Gemini', () => {
|
|
35
|
+
vitest_1.it.each([
|
|
36
|
+
['gemini-2.5-flash', true],
|
|
37
|
+
['gemini-2.5-pro', true],
|
|
38
|
+
['gemini-3-pro', true],
|
|
39
|
+
['gemini-3.5-flash', true],
|
|
40
|
+
])('allows temperature for %s', (model, expected) => {
|
|
41
|
+
(0, vitest_1.expect)((0, ai_client_1.modelSupportsTemperature)(model)).toBe(expected);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
(0, vitest_1.describe)('Anthropic', () => {
|
|
45
|
+
vitest_1.it.each([
|
|
46
|
+
['claude-haiku-4-5', true],
|
|
47
|
+
['claude-haiku-4-5-20251001', true],
|
|
48
|
+
['claude-sonnet-4-5', true],
|
|
49
|
+
['claude-sonnet-4-5-20250929', true],
|
|
50
|
+
['claude-opus-4-5', true],
|
|
51
|
+
['claude-opus-4-5-20251101', true],
|
|
52
|
+
['claude-opus-4-6', true],
|
|
53
|
+
])('allows temperature for %s', (model, expected) => {
|
|
54
|
+
(0, vitest_1.expect)((0, ai_client_1.modelSupportsTemperature)(model)).toBe(expected);
|
|
55
|
+
});
|
|
56
|
+
vitest_1.it.each([
|
|
57
|
+
['claude-opus-4-7', false],
|
|
58
|
+
['claude-opus-4-7-20260101', false],
|
|
59
|
+
['CLAUDE-OPUS-4-7', false], // case-insensitive
|
|
60
|
+
])('rejects temperature for opus-4-7 variant %s', (model, expected) => {
|
|
61
|
+
(0, vitest_1.expect)((0, ai_client_1.modelSupportsTemperature)(model)).toBe(expected);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
(0, vitest_1.describe)('getDefaultModel', () => {
|
|
66
|
+
(0, vitest_1.it)('returns the configured fast and smart tiers for each provider', () => {
|
|
67
|
+
// Don't pin exact model strings — they will be upgraded over time. Just
|
|
68
|
+
// assert that each provider returns a non-empty string for both tiers
|
|
69
|
+
// and that fast/smart differ (smart is meant to be a bigger model).
|
|
70
|
+
for (const provider of ['openai', 'gemini', 'anthropic']) {
|
|
71
|
+
const fast = (0, ai_client_1.getDefaultModel)(provider, 'fast');
|
|
72
|
+
const smart = (0, ai_client_1.getDefaultModel)(provider, 'smart');
|
|
73
|
+
(0, vitest_1.expect)(fast).toBeTruthy();
|
|
74
|
+
(0, vitest_1.expect)(smart).toBeTruthy();
|
|
75
|
+
(0, vitest_1.expect)(fast).not.toEqual(smart);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
(0, vitest_1.it)('returns smart-tier models that are correctly classified by modelSupportsTemperature', () => {
|
|
79
|
+
// Regression guard: whenever a smart model is upgraded, the helper must
|
|
80
|
+
// continue to return the correct policy for it. This test is the single
|
|
81
|
+
// place that ties the two together so an accidental mismatch breaks the
|
|
82
|
+
// suite immediately.
|
|
83
|
+
const expectations = {
|
|
84
|
+
// OpenAI smart tier is in the GPT-5 family → no temperature.
|
|
85
|
+
openai: false,
|
|
86
|
+
// Gemini smart tier accepts temperature.
|
|
87
|
+
gemini: true,
|
|
88
|
+
// Anthropic smart tier is claude-opus-4-7 → no temperature.
|
|
89
|
+
anthropic: false,
|
|
90
|
+
};
|
|
91
|
+
for (const provider of Object.keys(expectations)) {
|
|
92
|
+
const expected = expectations[provider];
|
|
93
|
+
if (expected === null)
|
|
94
|
+
continue;
|
|
95
|
+
const smartModel = (0, ai_client_1.getDefaultModel)(provider, 'smart');
|
|
96
|
+
(0, vitest_1.expect)((0, ai_client_1.modelSupportsTemperature)(smartModel), `${provider} smart model "${smartModel}" should report temperature-support=${expected}`).toBe(expected);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Tests for the temperature-handling change in `runEnhancementModel`.
|
|
4
|
+
*
|
|
5
|
+
* - 'enhance' → { temperature: 0.3 }
|
|
6
|
+
* - 'grammar' → { temperature: 0.3 }
|
|
7
|
+
* - 'task' → {} (no temperature; smart-tier model decides for itself)
|
|
8
|
+
*
|
|
9
|
+
* Mocks `./ai-client` and `./models/subscriptionTaskTemplate` so the test
|
|
10
|
+
* stays a pure unit test and never touches the database or any SDK.
|
|
11
|
+
*/
|
|
12
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
13
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
14
|
+
};
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
const vitest_1 = require("vitest");
|
|
17
|
+
const winston_1 = __importDefault(require("winston"));
|
|
18
|
+
const mocks = vitest_1.vi.hoisted(() => ({
|
|
19
|
+
streamComplete: vitest_1.vi.fn(),
|
|
20
|
+
getDefaultModel: vitest_1.vi.fn(),
|
|
21
|
+
findOne: vitest_1.vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
vitest_1.vi.mock('../ai-client', () => ({
|
|
24
|
+
aiClient: { streamComplete: mocks.streamComplete },
|
|
25
|
+
getDefaultModel: mocks.getDefaultModel,
|
|
26
|
+
}));
|
|
27
|
+
vitest_1.vi.mock('../models/subscriptionTaskTemplate', () => ({
|
|
28
|
+
SubscriptionTaskTemplate: { findOne: mocks.findOne },
|
|
29
|
+
}));
|
|
30
|
+
const featureRoutes_1 = require("../featureRoutes");
|
|
31
|
+
function makeLogger() {
|
|
32
|
+
return winston_1.default.createLogger({
|
|
33
|
+
silent: true,
|
|
34
|
+
transports: [new winston_1.default.transports.Console({ silent: true })],
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const fakeSubscription = { id: 'sub_test' };
|
|
38
|
+
(0, vitest_1.beforeEach)(() => {
|
|
39
|
+
mocks.streamComplete.mockReset();
|
|
40
|
+
mocks.streamComplete.mockResolvedValue({ usage: undefined, model: 'mock-model' });
|
|
41
|
+
mocks.getDefaultModel.mockReset();
|
|
42
|
+
mocks.getDefaultModel.mockImplementation((_provider, tier) => tier === 'smart' ? 'smart-model-mock' : 'fast-model-mock');
|
|
43
|
+
mocks.findOne.mockReset();
|
|
44
|
+
// Default task template — plain text passes through `decompressString`
|
|
45
|
+
// so `getPromptForCommand('task', ...)` returns a non-empty prompt and the
|
|
46
|
+
// streamComplete path is reached.
|
|
47
|
+
mocks.findOne.mockResolvedValue({ instructions: 'You are a helpful task assistant.' });
|
|
48
|
+
});
|
|
49
|
+
(0, vitest_1.describe)('runEnhancementModel — temperature per command', () => {
|
|
50
|
+
(0, vitest_1.it)("passes temperature: 0.3 for cmd='enhance'", async () => {
|
|
51
|
+
const result = await (0, featureRoutes_1.runEnhancementModel)(makeLogger(), 'hello world', 'enhance', fakeSubscription);
|
|
52
|
+
(0, vitest_1.expect)(result).not.toBeNull();
|
|
53
|
+
(0, vitest_1.expect)(mocks.streamComplete).toHaveBeenCalledTimes(1);
|
|
54
|
+
const [, , options] = mocks.streamComplete.mock.calls[0];
|
|
55
|
+
(0, vitest_1.expect)(options).toEqual({ temperature: 0.3 });
|
|
56
|
+
});
|
|
57
|
+
(0, vitest_1.it)("passes temperature: 0.3 for cmd='grammar'", async () => {
|
|
58
|
+
const result = await (0, featureRoutes_1.runEnhancementModel)(makeLogger(), 'helo wrld', 'grammar', fakeSubscription);
|
|
59
|
+
(0, vitest_1.expect)(result).not.toBeNull();
|
|
60
|
+
(0, vitest_1.expect)(mocks.streamComplete).toHaveBeenCalledTimes(1);
|
|
61
|
+
const [, , options] = mocks.streamComplete.mock.calls[0];
|
|
62
|
+
(0, vitest_1.expect)(options).toEqual({ temperature: 0.3 });
|
|
63
|
+
});
|
|
64
|
+
(0, vitest_1.it)("omits temperature for cmd='task' (custom-task)", async () => {
|
|
65
|
+
const result = await (0, featureRoutes_1.runEnhancementModel)(makeLogger(), 'do the thing', 'task', fakeSubscription);
|
|
66
|
+
(0, vitest_1.expect)(result).not.toBeNull();
|
|
67
|
+
(0, vitest_1.expect)(mocks.streamComplete).toHaveBeenCalledTimes(1);
|
|
68
|
+
const [, , options] = mocks.streamComplete.mock.calls[0];
|
|
69
|
+
(0, vitest_1.expect)(options).toEqual({});
|
|
70
|
+
(0, vitest_1.expect)(options).not.toHaveProperty('temperature');
|
|
71
|
+
});
|
|
72
|
+
(0, vitest_1.it)("selects the smart-tier model for cmd='task' and fast-tier for enhance/grammar", async () => {
|
|
73
|
+
await (0, featureRoutes_1.runEnhancementModel)(makeLogger(), 'a', 'task', fakeSubscription);
|
|
74
|
+
await (0, featureRoutes_1.runEnhancementModel)(makeLogger(), 'b', 'enhance', fakeSubscription);
|
|
75
|
+
await (0, featureRoutes_1.runEnhancementModel)(makeLogger(), 'c', 'grammar', fakeSubscription);
|
|
76
|
+
const modelsCalled = mocks.streamComplete.mock.calls.map(([model]) => model);
|
|
77
|
+
(0, vitest_1.expect)(modelsCalled).toEqual(['smart-model-mock', 'fast-model-mock', 'fast-model-mock']);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.aiClient = exports.AIClient = void 0;
|
|
7
7
|
exports.getDefaultModel = getDefaultModel;
|
|
8
|
+
exports.modelSupportsTemperature = modelSupportsTemperature;
|
|
8
9
|
exports.getMaxMessageContentLength = getMaxMessageContentLength;
|
|
9
10
|
exports.getMaxHistoryLength = getMaxHistoryLength;
|
|
10
11
|
exports.getContextWindowSize = getContextWindowSize;
|
|
@@ -17,6 +18,12 @@ const config_1 = require("./config");
|
|
|
17
18
|
// Default model mapping
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
const DEFAULT_MODELS = {
|
|
21
|
+
// Smart-tier picks track each provider's current flagship for
|
|
22
|
+
// reasoning/coding workloads. Update here when a newer model becomes
|
|
23
|
+
// generally available so both the feature routes and the agent server pick
|
|
24
|
+
// it up automatically. When swapping a smart model in, also verify whether
|
|
25
|
+
// it accepts the `temperature` parameter and update
|
|
26
|
+
// `modelSupportsTemperature` accordingly.
|
|
20
27
|
openai: { fast: 'gpt-4o-mini', smart: 'gpt-5.5' },
|
|
21
28
|
gemini: { fast: 'gemini-2.5-flash', smart: 'gemini-2.5-pro' },
|
|
22
29
|
anthropic: { fast: 'claude-haiku-4-5-20251001', smart: 'claude-opus-4-7' },
|
|
@@ -24,6 +31,39 @@ const DEFAULT_MODELS = {
|
|
|
24
31
|
function getDefaultModel(provider, tier) {
|
|
25
32
|
return DEFAULT_MODELS[provider][tier];
|
|
26
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Returns whether a given model accepts the `temperature` parameter.
|
|
36
|
+
*
|
|
37
|
+
* Provider-specific rules (validated against published API docs and SDKs as
|
|
38
|
+
* of late 2025 / early 2026):
|
|
39
|
+
* - OpenAI GPT-5 family (`gpt-5`, `gpt-5-mini`, `gpt-5.1`, …): NOT supported.
|
|
40
|
+
* The API only accepts the default value (1) and returns
|
|
41
|
+
* `unsupported_value: 'temperature'` for anything else.
|
|
42
|
+
* - OpenAI o-series reasoning models (`o1`, `o3`, `o4-mini`, …): NOT
|
|
43
|
+
* supported for the same reason.
|
|
44
|
+
* - OpenAI GPT-4 / GPT-4o / GPT-3.5: supported.
|
|
45
|
+
* - Google Gemini (2.x and 3.x families): supported via `generationConfig`.
|
|
46
|
+
* - Anthropic Claude (Sonnet, Haiku, and Opus 4.x): supported, with the
|
|
47
|
+
* exception of `claude-opus-4-7` (and its dated revisions) which rejects
|
|
48
|
+
* `temperature` just like the OpenAI GPT-5 family.
|
|
49
|
+
*/
|
|
50
|
+
function modelSupportsTemperature(model) {
|
|
51
|
+
// OpenAI GPT-5 family (gpt-5, gpt-5-mini, gpt-5.1, gpt-5.5, …) only
|
|
52
|
+
// accepts the default temperature (1) — anything else is rejected with
|
|
53
|
+
// `unsupported_value: 'temperature'`.
|
|
54
|
+
if (/^gpt-5(\b|[.\-])/i.test(model))
|
|
55
|
+
return false;
|
|
56
|
+
// OpenAI o-series reasoning models (o1, o3, o4-mini, …) likewise drop the
|
|
57
|
+
// `temperature` knob.
|
|
58
|
+
if (/^o[134](\b|[-_])/i.test(model))
|
|
59
|
+
return false;
|
|
60
|
+
// Anthropic's Claude Opus 4.7 line (and its dated revisions like
|
|
61
|
+
// `claude-opus-4-7-20260101`) does not accept `temperature`; the rest of
|
|
62
|
+
// the Claude 4.x family (Sonnet, Haiku, Opus 4.5/4.6) does.
|
|
63
|
+
if (/^claude-opus-4-7(\b|[-_])/i.test(model))
|
|
64
|
+
return false;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
27
67
|
/**
|
|
28
68
|
* Maximum character length for a single message content string per provider.
|
|
29
69
|
*
|
|
@@ -88,7 +128,7 @@ class OpenAIAdapter {
|
|
|
88
128
|
model,
|
|
89
129
|
messages: oaiMessages,
|
|
90
130
|
tools: tools?.length ? tools : undefined,
|
|
91
|
-
|
|
131
|
+
...(modelSupportsTemperature(model) ? { temperature: options.temperature ?? 0.2 } : {}),
|
|
92
132
|
max_tokens: options.maxTokens,
|
|
93
133
|
});
|
|
94
134
|
const choice = completion.choices[0];
|
|
@@ -125,7 +165,9 @@ class OpenAIAdapter {
|
|
|
125
165
|
const stream = await this.client.chat.completions.create({
|
|
126
166
|
model,
|
|
127
167
|
messages: oaiMessages,
|
|
128
|
-
|
|
168
|
+
...(modelSupportsTemperature(model)
|
|
169
|
+
? { temperature: options.temperature ?? 0.3 }
|
|
170
|
+
: {}),
|
|
129
171
|
stream: true,
|
|
130
172
|
stream_options: { include_usage: true },
|
|
131
173
|
});
|
|
@@ -188,7 +230,7 @@ class AnthropicAdapter {
|
|
|
188
230
|
...(system ? { system } : {}),
|
|
189
231
|
messages: anthropicMessages,
|
|
190
232
|
...(tools?.length ? { tools } : {}),
|
|
191
|
-
...(model
|
|
233
|
+
...(modelSupportsTemperature(model) ? { temperature: options.temperature ?? 0.2 } : {}),
|
|
192
234
|
});
|
|
193
235
|
const textContent = response.content
|
|
194
236
|
.filter((b) => b.type === 'text')
|
|
@@ -235,7 +277,9 @@ class AnthropicAdapter {
|
|
|
235
277
|
max_tokens: options.maxTokens ?? 8192,
|
|
236
278
|
...(system ? { system } : {}),
|
|
237
279
|
messages: anthropicMessages,
|
|
238
|
-
|
|
280
|
+
...(modelSupportsTemperature(model)
|
|
281
|
+
? { temperature: options.temperature ?? 0.3 }
|
|
282
|
+
: {}),
|
|
239
283
|
});
|
|
240
284
|
for await (const event of stream) {
|
|
241
285
|
if (event.type === 'content_block_delta' &&
|
|
@@ -269,7 +313,9 @@ class GeminiAdapter {
|
|
|
269
313
|
config: {
|
|
270
314
|
...(systemInstruction ? { systemInstruction } : {}),
|
|
271
315
|
...(tools?.length ? { tools } : {}),
|
|
272
|
-
|
|
316
|
+
...(modelSupportsTemperature(model)
|
|
317
|
+
? { temperature: options.temperature ?? 0.2 }
|
|
318
|
+
: {}),
|
|
273
319
|
},
|
|
274
320
|
});
|
|
275
321
|
const candidate = response.candidates?.[0];
|
|
@@ -320,7 +366,9 @@ class GeminiAdapter {
|
|
|
320
366
|
contents,
|
|
321
367
|
config: {
|
|
322
368
|
...(systemInstruction ? { systemInstruction } : {}),
|
|
323
|
-
|
|
369
|
+
...(modelSupportsTemperature(model)
|
|
370
|
+
? { temperature: options.temperature ?? 0.3 }
|
|
371
|
+
: {}),
|
|
324
372
|
},
|
|
325
373
|
});
|
|
326
374
|
let usage;
|
|
@@ -56,13 +56,12 @@ async function getPromptForCommand(logger, cmd, subscription) {
|
|
|
56
56
|
return '';
|
|
57
57
|
}
|
|
58
58
|
function getModelForCommand(cmd) {
|
|
59
|
+
// 'task' is the custom-task command and routes to the smart-tier model.
|
|
60
|
+
// 'enhance' and 'grammar' use the fast tier. The actual model strings live
|
|
61
|
+
// in ai-client.ts (DEFAULT_MODELS) so all callers stay in sync when we
|
|
62
|
+
// upgrade to a newer flagship model.
|
|
59
63
|
const tier = cmd === 'task' ? 'smart' : 'fast';
|
|
60
|
-
|
|
61
|
-
openai: { fast: 'gpt-4o-mini', smart: 'gpt-5.5' },
|
|
62
|
-
gemini: { fast: 'gemini-2.5-flash', smart: 'gemini-2.5-pro' },
|
|
63
|
-
anthropic: { fast: 'claude-haiku-4-5-20251001', smart: 'claude-opus-4-7' },
|
|
64
|
-
};
|
|
65
|
-
return models[config_1.config.aiProvider]?.[tier] ?? 'gpt-4o-mini';
|
|
64
|
+
return (0, ai_client_1.getDefaultModel)(config_1.config.aiProvider, tier);
|
|
66
65
|
}
|
|
67
66
|
function createMessagesParams(cmd, input, prompt) {
|
|
68
67
|
if (cmd === 'task') {
|
|
@@ -100,7 +99,14 @@ async function runEnhancementModel(logger, text, cmd, subscription, onDelta) {
|
|
|
100
99
|
const messages = createMessagesParams(cmd, trimmed, prompt);
|
|
101
100
|
let rawResponse = '';
|
|
102
101
|
let usage;
|
|
103
|
-
|
|
102
|
+
// Smart-tier models (used by the custom-task command) include OpenAI's
|
|
103
|
+
// GPT-5 family, which rejects any non-default `temperature`. Even on
|
|
104
|
+
// providers where the smart model still accepts it (Gemini, Anthropic),
|
|
105
|
+
// omitting `temperature` keeps the request shape uniform across providers
|
|
106
|
+
// and lets each model use its own tuned default. The fast-tier models used
|
|
107
|
+
// by `enhance` and `grammar` keep the previous 0.3 default.
|
|
108
|
+
const completionOptions = cmd === 'task' ? {} : { temperature: 0.3 };
|
|
109
|
+
const result = await ai_client_1.aiClient.streamComplete(model, messages, completionOptions, (delta) => {
|
|
104
110
|
rawResponse += delta;
|
|
105
111
|
if (onDelta)
|
|
106
112
|
onDelta(delta);
|
package/backend-dist/index.js
CHANGED
|
@@ -15,8 +15,8 @@ const logger_1 = require("./logger");
|
|
|
15
15
|
const taskInstructionRoutes_1 = require("./taskInstructionRoutes");
|
|
16
16
|
const scheduledJobRoutes_1 = require("./scheduledJobRoutes");
|
|
17
17
|
const mcpServerRoutes_1 = require("./mcpServerRoutes");
|
|
18
|
-
const
|
|
19
|
-
const
|
|
18
|
+
const spawn_1 = require("./workers/spawn");
|
|
19
|
+
const scheduledJobWorkerClient_1 = require("./workers/scheduledJobWorkerClient");
|
|
20
20
|
const config_1 = require("./config");
|
|
21
21
|
const agentServer_1 = require("./agent/agentServer");
|
|
22
22
|
// Importing AgentSession and ScheduledJob ensures the models are registered with Sequelize before initDatabase().
|
|
@@ -185,6 +185,7 @@ app.get('*', (_req, res) => {
|
|
|
185
185
|
res.sendFile(path_1.default.join(process.cwd(), 'public', 'index.html'));
|
|
186
186
|
});
|
|
187
187
|
let server = null;
|
|
188
|
+
const backgroundWorkers = [];
|
|
188
189
|
async function start() {
|
|
189
190
|
try {
|
|
190
191
|
await (0, db_1.initDatabase)(logger_1.logger);
|
|
@@ -201,8 +202,15 @@ async function start() {
|
|
|
201
202
|
(0, agentServer_1.attachAgentWebSocketServer)(server);
|
|
202
203
|
}
|
|
203
204
|
if (config_1.config.isSelfHosted) {
|
|
204
|
-
|
|
205
|
-
|
|
205
|
+
// Run the schedulers in dedicated worker threads so their DB
|
|
206
|
+
// polling / cron ticks never block the HTTP event loop.
|
|
207
|
+
const scheduledJobWorker = (0, spawn_1.spawnWorker)('scheduledJobWorker');
|
|
208
|
+
backgroundWorkers.push(scheduledJobWorker);
|
|
209
|
+
backgroundWorkers.push((0, spawn_1.spawnWorker)('groupingWorker'));
|
|
210
|
+
// Expose the worker handle so HTTP routes (e.g. POST /:id/run-now)
|
|
211
|
+
// can dispatch immediate executions into the worker thread instead
|
|
212
|
+
// of running them in-process and blocking the event loop.
|
|
213
|
+
(0, scheduledJobWorkerClient_1.setScheduledJobWorker)(scheduledJobWorker);
|
|
206
214
|
}
|
|
207
215
|
}
|
|
208
216
|
catch (err) {
|
|
@@ -211,21 +219,31 @@ async function start() {
|
|
|
211
219
|
}
|
|
212
220
|
}
|
|
213
221
|
start();
|
|
222
|
+
async function stopBackgroundWorkers() {
|
|
223
|
+
if (!backgroundWorkers.length)
|
|
224
|
+
return;
|
|
225
|
+
logger_1.logger.info('Stopping background workers...', { count: backgroundWorkers.length });
|
|
226
|
+
(0, scheduledJobWorkerClient_1.setScheduledJobWorker)(null);
|
|
227
|
+
await Promise.allSettled(backgroundWorkers.map((w) => w.stop()));
|
|
228
|
+
}
|
|
214
229
|
function gracefulShutdown(signal) {
|
|
215
230
|
logger_1.logger.info(`Received ${signal}. Starting graceful shutdown...`);
|
|
231
|
+
const finish = (code) => {
|
|
232
|
+
void stopBackgroundWorkers().finally(() => process.exit(code));
|
|
233
|
+
};
|
|
216
234
|
if (!server) {
|
|
217
235
|
logger_1.logger.info('Server was not started or already closed. Exiting process.');
|
|
218
|
-
|
|
236
|
+
finish(0);
|
|
219
237
|
return;
|
|
220
238
|
}
|
|
221
239
|
server.close((err) => {
|
|
222
240
|
if (err) {
|
|
223
241
|
logger_1.logger.error('Error during HTTP server shutdown.', { error: err });
|
|
224
|
-
|
|
242
|
+
finish(1);
|
|
225
243
|
return;
|
|
226
244
|
}
|
|
227
245
|
logger_1.logger.info('HTTP server closed. Exiting process.');
|
|
228
|
-
|
|
246
|
+
finish(0);
|
|
229
247
|
});
|
|
230
248
|
}
|
|
231
249
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
@@ -9,6 +9,7 @@ const zod_1 = __importDefault(require("zod"));
|
|
|
9
9
|
const authMiddleware_1 = require("./authMiddleware");
|
|
10
10
|
const scheduledJob_1 = require("./models/scheduledJob");
|
|
11
11
|
const scheduledJobExecutor_1 = require("./scheduledJobExecutor");
|
|
12
|
+
const scheduledJobWorkerClient_1 = require("./workers/scheduledJobWorkerClient");
|
|
12
13
|
const CRON_REGEX = /^(\S+\s){4}\S+$/;
|
|
13
14
|
const jobSchema = zod_1.default.object({
|
|
14
15
|
label: zod_1.default.string().min(1).max(200),
|
|
@@ -172,9 +173,19 @@ function scheduledJobRouter() {
|
|
|
172
173
|
if (!job) {
|
|
173
174
|
return res.status(404).json({ error: 'Scheduled job not found.' });
|
|
174
175
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
// Prefer the background worker (self-hosted) so the execution
|
|
177
|
+
// doesn't block this HTTP handler or any other request on the main
|
|
178
|
+
// event loop. Fall back to in-process execution when no worker is
|
|
179
|
+
// running (e.g. cloud deployments or during shutdown).
|
|
180
|
+
const dispatched = (0, scheduledJobWorkerClient_1.triggerJobInWorker)(job.id);
|
|
181
|
+
if (!dispatched) {
|
|
182
|
+
void (0, scheduledJobExecutor_1.executeJob)(job).catch((err) => {
|
|
183
|
+
logger.error('run-now execution failed.', { jobId: job.id, error: err });
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
logger.info('run-now dispatched to scheduledJobWorker.', { jobId: job.id });
|
|
188
|
+
}
|
|
178
189
|
res.json(formatJob(job));
|
|
179
190
|
}
|
|
180
191
|
catch (err) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const workerBootstrap_1 = require("./workerBootstrap");
|
|
4
|
+
const sessionGrouping_1 = require("../agent/sessionGrouping");
|
|
5
|
+
void (0, workerBootstrap_1.bootstrapWorker)('groupingWorker', () => {
|
|
6
|
+
(0, sessionGrouping_1.startGroupingCronJob)();
|
|
7
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const scheduledJob_1 = require("../models/scheduledJob");
|
|
4
|
+
const scheduledJobExecutor_1 = require("../scheduledJobExecutor");
|
|
5
|
+
const logger_1 = require("../logger");
|
|
6
|
+
const workerBootstrap_1 = require("./workerBootstrap");
|
|
7
|
+
void (0, workerBootstrap_1.bootstrapWorker)('scheduledJobWorker', () => {
|
|
8
|
+
(0, scheduledJobExecutor_1.startScheduledJobExecutor)();
|
|
9
|
+
}, {
|
|
10
|
+
onMessage: async (msg) => {
|
|
11
|
+
if (msg.type !== 'runJob')
|
|
12
|
+
return;
|
|
13
|
+
const job = await scheduledJob_1.ScheduledJob.findByPk(msg.jobId);
|
|
14
|
+
if (!job) {
|
|
15
|
+
logger_1.logger.warn('runJob: scheduled job not found in worker.', { jobId: msg.jobId });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
logger_1.logger.info('runJob: executing scheduled job inside worker.', {
|
|
19
|
+
jobId: job.id,
|
|
20
|
+
label: job.label,
|
|
21
|
+
});
|
|
22
|
+
// executeJob already guards against concurrent runs of the same jobId
|
|
23
|
+
// (via RUNNING_JOB_IDS) and updates lastRunAt / nextRunAt itself, so we
|
|
24
|
+
// just fire-and-forget here and let errors surface through its own
|
|
25
|
+
// logger.error call.
|
|
26
|
+
await (0, scheduledJobExecutor_1.executeJob)(job).catch((err) => {
|
|
27
|
+
logger_1.logger.error('runJob: execution failed.', { jobId: job.id, error: err });
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.setScheduledJobWorker = setScheduledJobWorker;
|
|
4
|
+
exports.triggerJobInWorker = triggerJobInWorker;
|
|
5
|
+
let scheduledJobWorker = null;
|
|
6
|
+
/**
|
|
7
|
+
* Register the scheduledJobWorker handle so HTTP routes can post messages to
|
|
8
|
+
* it. Called once from `index.ts` after the worker is spawned.
|
|
9
|
+
*/
|
|
10
|
+
function setScheduledJobWorker(worker) {
|
|
11
|
+
scheduledJobWorker = worker;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Ask the scheduledJobWorker to execute a job immediately. Returns true if
|
|
15
|
+
* the message was dispatched, false if no worker is currently running (e.g.
|
|
16
|
+
* non-self-hosted deployments where the executor still runs in-process). The
|
|
17
|
+
* caller is expected to fall back to an in-process `executeJob` when this
|
|
18
|
+
* returns false.
|
|
19
|
+
*/
|
|
20
|
+
function triggerJobInWorker(jobId) {
|
|
21
|
+
if (!scheduledJobWorker)
|
|
22
|
+
return false;
|
|
23
|
+
scheduledJobWorker.postMessage({ type: 'runJob', jobId });
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.spawnWorker = spawnWorker;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const worker_threads_1 = require("worker_threads");
|
|
9
|
+
const logger_1 = require("../logger");
|
|
10
|
+
// Distance (ms) between auto-restart attempts after an unexpected exit.
|
|
11
|
+
// Keeps a crashed worker from spinning a tight restart loop while still
|
|
12
|
+
// recovering quickly from transient errors.
|
|
13
|
+
const RESTART_DELAY_MS = 5000;
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the on-disk path to a worker entry file.
|
|
16
|
+
*
|
|
17
|
+
* - Production / `yarn start`: the project is compiled to `dist/` and the
|
|
18
|
+
* running file's extension is `.js`. The compiled worker lives next to
|
|
19
|
+
* this file at `dist/workers/<name>.js`.
|
|
20
|
+
* - Development / `yarn dev`: ts-node-dev runs the original `.ts` sources.
|
|
21
|
+
* `__filename` ends with `.ts`, so we point the worker at the matching
|
|
22
|
+
* `.ts` file and rely on `ts-node/register` (loaded via `execArgv`) to
|
|
23
|
+
* transpile it on the fly inside the worker thread.
|
|
24
|
+
*/
|
|
25
|
+
function resolveWorkerEntry(workerName) {
|
|
26
|
+
const isTs = __filename.endsWith('.ts');
|
|
27
|
+
const ext = isTs ? '.ts' : '.js';
|
|
28
|
+
const entry = path_1.default.join(__dirname, `${workerName}${ext}`);
|
|
29
|
+
return { entry, isTs };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Spawn a worker thread and automatically restart it if it exits unexpectedly.
|
|
33
|
+
*
|
|
34
|
+
* The returned handle exposes a `postMessage()` method for typed RPC and a
|
|
35
|
+
* `stop()` method that disables auto-restart before terminating the worker.
|
|
36
|
+
*/
|
|
37
|
+
function spawnWorker(workerName) {
|
|
38
|
+
let stopped = false;
|
|
39
|
+
let restartTimer = null;
|
|
40
|
+
const handle = {
|
|
41
|
+
name: workerName,
|
|
42
|
+
// Replaced by start(); seeded with a placeholder so TS is happy.
|
|
43
|
+
worker: null,
|
|
44
|
+
postMessage: (msg) => {
|
|
45
|
+
const w = handle.worker;
|
|
46
|
+
if (!w) {
|
|
47
|
+
logger_1.logger.warn('Dropping message to worker that has not started yet.', {
|
|
48
|
+
workerName,
|
|
49
|
+
messageType: msg.type,
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
w.postMessage(msg);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
logger_1.logger.error('Failed to post message to worker.', {
|
|
58
|
+
workerName,
|
|
59
|
+
messageType: msg.type,
|
|
60
|
+
error: err,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
stop: async () => {
|
|
65
|
+
stopped = true;
|
|
66
|
+
if (restartTimer) {
|
|
67
|
+
clearTimeout(restartTimer);
|
|
68
|
+
restartTimer = null;
|
|
69
|
+
}
|
|
70
|
+
const w = handle.worker;
|
|
71
|
+
if (!w)
|
|
72
|
+
return;
|
|
73
|
+
try {
|
|
74
|
+
await w.terminate();
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
logger_1.logger.error('Failed to terminate worker.', { workerName, error: err });
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
const start = () => {
|
|
82
|
+
const { entry, isTs } = resolveWorkerEntry(workerName);
|
|
83
|
+
const execArgv = isTs ? ['-r', 'ts-node/register/transpile-only'] : [];
|
|
84
|
+
logger_1.logger.info('Spawning background worker.', { workerName, entry });
|
|
85
|
+
const worker = new worker_threads_1.Worker(entry, {
|
|
86
|
+
execArgv,
|
|
87
|
+
// Forward stdout/stderr through the parent so worker logs surface in the
|
|
88
|
+
// same console / log aggregator as the main process.
|
|
89
|
+
stdout: false,
|
|
90
|
+
stderr: false,
|
|
91
|
+
});
|
|
92
|
+
handle.worker = worker;
|
|
93
|
+
worker.on('error', (err) => {
|
|
94
|
+
logger_1.logger.error('Worker emitted error.', { workerName, error: err });
|
|
95
|
+
});
|
|
96
|
+
worker.on('exit', (code) => {
|
|
97
|
+
if (stopped) {
|
|
98
|
+
logger_1.logger.info('Worker exited after stop.', { workerName, code });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
logger_1.logger.error('Worker exited unexpectedly; scheduling restart.', {
|
|
102
|
+
workerName,
|
|
103
|
+
code,
|
|
104
|
+
restartDelayMs: RESTART_DELAY_MS,
|
|
105
|
+
});
|
|
106
|
+
restartTimer = setTimeout(() => {
|
|
107
|
+
restartTimer = null;
|
|
108
|
+
if (!stopped)
|
|
109
|
+
start();
|
|
110
|
+
}, RESTART_DELAY_MS);
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
start();
|
|
114
|
+
return handle;
|
|
115
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.bootstrapWorker = bootstrapWorker;
|
|
4
|
+
const worker_threads_1 = require("worker_threads");
|
|
5
|
+
const db_1 = require("../db");
|
|
6
|
+
const logger_1 = require("../logger");
|
|
7
|
+
// Importing the models registers them with Sequelize before initDatabase()
|
|
8
|
+
// runs inside the worker. Workers share the same model definitions as the
|
|
9
|
+
// main process but each owns its own Sequelize connection.
|
|
10
|
+
require("../models/agentSession");
|
|
11
|
+
require("../models/scheduledJob");
|
|
12
|
+
require("../models/mcpServer");
|
|
13
|
+
/**
|
|
14
|
+
* Initialize a background worker thread.
|
|
15
|
+
*
|
|
16
|
+
* Each worker owns its own Sequelize connection (db.ts is evaluated fresh in
|
|
17
|
+
* the worker's V8 isolate), so DB calls performed here do not contend with
|
|
18
|
+
* the main process's connection pool or block the HTTP event loop.
|
|
19
|
+
*
|
|
20
|
+
* The `run` callback receives the initialized logger and should kick off any
|
|
21
|
+
* long-running schedulers. It must never throw — uncaught errors crash the
|
|
22
|
+
* worker, and the parent will auto-restart it after a short delay.
|
|
23
|
+
*/
|
|
24
|
+
async function bootstrapWorker(workerName, run, options = {}) {
|
|
25
|
+
try {
|
|
26
|
+
await (0, db_1.initDatabase)(logger_1.logger);
|
|
27
|
+
logger_1.logger.info('Worker database connection ready.', { workerName });
|
|
28
|
+
await run();
|
|
29
|
+
logger_1.logger.info('Worker entry returned; scheduler is now active.', { workerName });
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
logger_1.logger.error('Worker bootstrap failed.', { workerName, error: err });
|
|
33
|
+
// Exit non-zero so the parent's `exit` handler treats this as a crash
|
|
34
|
+
// and schedules a restart instead of silently leaving the worker dead.
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
worker_threads_1.parentPort?.on('message', (raw) => {
|
|
38
|
+
// Defensive: messages can technically be any structured-clone-safe value.
|
|
39
|
+
const msg = raw;
|
|
40
|
+
if (!msg || typeof msg !== 'object' || typeof msg.type !== 'string') {
|
|
41
|
+
logger_1.logger.warn('Worker received malformed message; ignoring.', { workerName, raw });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (msg.type === 'shutdown') {
|
|
45
|
+
logger_1.logger.info('Worker received shutdown signal; exiting.', { workerName });
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
if (options.onMessage) {
|
|
49
|
+
void (async () => {
|
|
50
|
+
try {
|
|
51
|
+
await options.onMessage(msg);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
logger_1.logger.error('Worker message handler threw.', {
|
|
55
|
+
workerName,
|
|
56
|
+
messageType: msg.type,
|
|
57
|
+
error: err,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"access": "public",
|
|
5
5
|
"registry": "https://registry.npmjs.org/"
|
|
6
6
|
},
|
|
7
|
-
"version": "1.
|
|
7
|
+
"version": "1.4.0",
|
|
8
8
|
"description": "CLI for onboarding users to Omnikey AI and configuring OPENAI_API_KEY. Use Yarn for install/build.",
|
|
9
9
|
"engines": {
|
|
10
10
|
"node": ">=14.0.0",
|