vellum 0.2.7 → 0.2.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/bun.lock +4 -4
- package/package.json +4 -3
- package/src/__tests__/asset-materialize-tool.test.ts +2 -2
- package/src/__tests__/checker.test.ts +104 -0
- package/src/__tests__/config-schema.test.ts +0 -6
- package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +538 -0
- package/src/__tests__/ingress-url-consistency.test.ts +214 -0
- package/src/__tests__/ipc-snapshot.test.ts +17 -5
- package/src/__tests__/oauth-callback-registry.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +304 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
- package/src/__tests__/public-ingress-urls.test.ts +222 -0
- package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
- package/src/__tests__/runtime-events-sse.test.ts +162 -0
- package/src/__tests__/tool-executor.test.ts +88 -0
- package/src/__tests__/turn-commit.test.ts +64 -0
- package/src/__tests__/twilio-provider.test.ts +1 -1
- package/src/__tests__/twilio-routes.test.ts +4 -4
- package/src/__tests__/twitter-auth-handler.test.ts +87 -2
- package/src/calls/call-domain.ts +8 -6
- package/src/calls/twilio-config.ts +18 -3
- package/src/calls/twilio-routes.ts +10 -2
- package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
- package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
- package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
- package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
- package/src/config/defaults.ts +4 -1
- package/src/config/schema.ts +30 -6
- package/src/config/system-prompt.ts +1 -1
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
- package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
- package/src/daemon/computer-use-session.ts +2 -1
- package/src/daemon/handlers/config.ts +49 -17
- package/src/daemon/handlers/sessions.ts +2 -2
- package/src/daemon/handlers/shared.ts +1 -0
- package/src/daemon/handlers/subagents.ts +85 -2
- package/src/daemon/handlers/twitter-auth.ts +31 -2
- package/src/daemon/handlers/work-items.ts +1 -1
- package/src/daemon/ipc-contract-inventory.json +8 -4
- package/src/daemon/ipc-contract.ts +34 -15
- package/src/daemon/lifecycle.ts +9 -4
- package/src/daemon/server.ts +7 -0
- package/src/daemon/session-tool-setup.ts +8 -1
- package/src/inbound/public-ingress-urls.ts +112 -0
- package/src/memory/attachments-store.ts +0 -1
- package/src/memory/channel-delivery-store.ts +0 -1
- package/src/memory/conversation-key-store.ts +0 -1
- package/src/memory/db.ts +472 -148
- package/src/memory/llm-usage-store.ts +0 -1
- package/src/memory/runs-store.ts +51 -6
- package/src/memory/schema.ts +2 -6
- package/src/runtime/gateway-client.ts +7 -1
- package/src/runtime/http-server.ts +174 -7
- package/src/runtime/routes/channel-routes.ts +7 -2
- package/src/runtime/routes/events-routes.ts +79 -0
- package/src/runtime/routes/run-routes.ts +43 -0
- package/src/runtime/run-orchestrator.ts +64 -7
- package/src/security/oauth-callback-registry.ts +66 -0
- package/src/security/oauth2.ts +208 -58
- package/src/subagent/manager.ts +3 -1
- package/src/swarm/backend-claude-code.ts +1 -1
- package/src/tools/assets/search.ts +1 -36
- package/src/tools/claude-code/claude-code.ts +3 -3
- package/src/tools/tasks/work-item-list.ts +16 -2
- package/src/tools/tasks/work-item-run.ts +78 -0
- package/src/util/platform.ts +1 -1
- package/src/work-items/work-item-runner.ts +171 -0
- package/src/workspace/provider-commit-message-generator.ts +39 -23
- package/src/workspace/turn-commit.ts +6 -2
- package/src/__tests__/handlers-twilio-config.test.ts +0 -221
- package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
- package/src/calls/twilio-webhook-urls.ts +0 -50
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ToolContext, ToolExecutionResult } from '../types.js';
|
|
2
|
+
import { getWorkItem, listWorkItems, identifyEntityById, buildWorkItemMismatchError } from '../../work-items/work-item-store.js';
|
|
3
|
+
import { runWorkItemInBackground } from '../../work-items/work-item-runner.js';
|
|
4
|
+
import { getTask } from '../../tasks/task-store.js';
|
|
5
|
+
|
|
6
|
+
export async function executeTaskQueueRun(
|
|
7
|
+
input: Record<string, unknown>,
|
|
8
|
+
_context: ToolContext,
|
|
9
|
+
): Promise<ToolExecutionResult> {
|
|
10
|
+
const workItemId = input.work_item_id as string | undefined;
|
|
11
|
+
const taskName = input.task_name as string | undefined;
|
|
12
|
+
const title = input.title as string | undefined;
|
|
13
|
+
|
|
14
|
+
if (!workItemId && !taskName && !title) {
|
|
15
|
+
return {
|
|
16
|
+
content: 'Error: Provide work_item_id, task_name, or title to identify the task to run.',
|
|
17
|
+
isError: true,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
let resolvedId: string | undefined;
|
|
23
|
+
|
|
24
|
+
if (workItemId) {
|
|
25
|
+
const item = getWorkItem(workItemId);
|
|
26
|
+
if (!item) {
|
|
27
|
+
const entity = identifyEntityById(workItemId);
|
|
28
|
+
if (entity.type === 'task_template') {
|
|
29
|
+
return {
|
|
30
|
+
content: `Error: "${workItemId}" is a task template ID, not a work item. Use task_list_show to find the work item ID.`,
|
|
31
|
+
isError: true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return { content: `Error: No work item found with ID "${workItemId}".`, isError: true };
|
|
35
|
+
}
|
|
36
|
+
resolvedId = item.id;
|
|
37
|
+
} else {
|
|
38
|
+
// Search by task_name or title among active work items
|
|
39
|
+
const needle = (taskName ?? title)!.toLowerCase();
|
|
40
|
+
const allItems = listWorkItems();
|
|
41
|
+
const activeItems = allItems.filter((i) => !['archived', 'done'].includes(i.status));
|
|
42
|
+
const matches = activeItems.filter((i) => i.title.toLowerCase().includes(needle));
|
|
43
|
+
|
|
44
|
+
if (matches.length === 0) {
|
|
45
|
+
return {
|
|
46
|
+
content: `Error: No active work item matching "${taskName ?? title}". Use task_list_show to see your task queue.`,
|
|
47
|
+
isError: true,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (matches.length > 1) {
|
|
52
|
+
const lines = [`Multiple work items match "${taskName ?? title}". Please specify by ID:`, ''];
|
|
53
|
+
for (const m of matches) {
|
|
54
|
+
lines.push(`- ${m.title} (ID: ${m.id}, status: ${m.status})`);
|
|
55
|
+
}
|
|
56
|
+
return { content: lines.join('\n'), isError: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
resolvedId = matches[0].id;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const result = runWorkItemInBackground(resolvedId);
|
|
63
|
+
|
|
64
|
+
if (!result.success) {
|
|
65
|
+
return { content: `Error: ${result.error}`, isError: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const item = getWorkItem(resolvedId)!;
|
|
69
|
+
const task = getTask(item.taskId);
|
|
70
|
+
return {
|
|
71
|
+
content: `Started running task "${item.title}"${task ? ` (template: ${task.title})` : ''}. It will execute in the background. Use task_list_show to check progress.`,
|
|
72
|
+
isError: false,
|
|
73
|
+
};
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
76
|
+
return { content: `Error: ${msg}`, isError: true };
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/util/platform.ts
CHANGED
|
@@ -124,7 +124,7 @@ export function getTCPPort(): number {
|
|
|
124
124
|
*
|
|
125
125
|
* The flag-file check makes it easy to enable TCP in dev without restarting
|
|
126
126
|
* the shell: `touch ~/.vellum/tcp-enabled && kill -USR1 <daemon-pid>`.
|
|
127
|
-
* The macOS
|
|
127
|
+
* The macOS CLI (AssistantCli) also sets the env var for bundled-binary deployments.
|
|
128
128
|
*/
|
|
129
129
|
export function isTCPEnabled(): boolean {
|
|
130
130
|
const override = process.env.VELLUM_DAEMON_TCP_ENABLED?.trim();
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level registry for running work items from tool context.
|
|
3
|
+
*
|
|
4
|
+
* The daemon server registers its `getOrCreateSession` and `broadcast`
|
|
5
|
+
* callbacks at startup. Tool implementations can then trigger async
|
|
6
|
+
* work item execution without needing direct access to HandlerContext.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getLogger } from '../util/logger.js';
|
|
10
|
+
import { getWorkItem, updateWorkItem, type WorkItemStatus } from './work-item-store.js';
|
|
11
|
+
import { getTask } from '../tasks/task-store.js';
|
|
12
|
+
import { runTask } from '../tasks/task-runner.js';
|
|
13
|
+
import { sanitizeToolList, getRegisteredToolNames } from '../tasks/tool-sanitizer.js';
|
|
14
|
+
import type { Session } from '../daemon/session.js';
|
|
15
|
+
import type { ServerMessage } from '../daemon/ipc-protocol.js';
|
|
16
|
+
|
|
17
|
+
const log = getLogger('work-item-runner');
|
|
18
|
+
|
|
19
|
+
// ── Daemon callback registry ─────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
interface DaemonCallbacks {
|
|
22
|
+
getOrCreateSession: (conversationId: string) => Promise<Session>;
|
|
23
|
+
broadcast: (msg: ServerMessage) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let _callbacks: DaemonCallbacks | null = null;
|
|
27
|
+
|
|
28
|
+
export function registerDaemonCallbacks(callbacks: DaemonCallbacks): void {
|
|
29
|
+
_callbacks = callbacks;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Public API ───────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function broadcastWorkItemStatus(broadcast: (msg: ServerMessage) => void, id: string): void {
|
|
35
|
+
const item = getWorkItem(id);
|
|
36
|
+
if (item) {
|
|
37
|
+
broadcast({
|
|
38
|
+
type: 'work_item_status_changed',
|
|
39
|
+
item: {
|
|
40
|
+
id: item.id,
|
|
41
|
+
taskId: item.taskId,
|
|
42
|
+
title: item.title,
|
|
43
|
+
status: item.status,
|
|
44
|
+
lastRunId: item.lastRunId,
|
|
45
|
+
lastRunConversationId: item.lastRunConversationId,
|
|
46
|
+
lastRunStatus: item.lastRunStatus,
|
|
47
|
+
updatedAt: item.updatedAt,
|
|
48
|
+
},
|
|
49
|
+
} as ServerMessage);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RunWorkItemResult {
|
|
54
|
+
success: boolean;
|
|
55
|
+
error?: string;
|
|
56
|
+
errorCode?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run a work item in the background. Returns immediately after validation.
|
|
61
|
+
* The actual execution happens asynchronously.
|
|
62
|
+
*
|
|
63
|
+
* When called from a chat tool (e.g. Telegram), required tools are
|
|
64
|
+
* auto-approved since the user explicitly requested execution.
|
|
65
|
+
*/
|
|
66
|
+
export function runWorkItemInBackground(workItemId: string): RunWorkItemResult {
|
|
67
|
+
if (!_callbacks) {
|
|
68
|
+
return { success: false, error: 'Daemon callbacks not registered', errorCode: 'not_initialized' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const workItem = getWorkItem(workItemId);
|
|
72
|
+
if (!workItem) {
|
|
73
|
+
return { success: false, error: 'Work item not found', errorCode: 'not_found' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (workItem.status === 'running') {
|
|
77
|
+
return { success: false, error: 'Work item is already running', errorCode: 'already_running' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const NON_RUNNABLE_STATUSES: readonly string[] = ['archived'];
|
|
81
|
+
if (NON_RUNNABLE_STATUSES.includes(workItem.status)) {
|
|
82
|
+
return { success: false, error: `Work item has status '${workItem.status}' and cannot be run`, errorCode: 'invalid_status' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const task = getTask(workItem.taskId);
|
|
86
|
+
if (!task) {
|
|
87
|
+
return { success: false, error: `Associated task not found: ${workItem.taskId}`, errorCode: 'no_task' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Resolve required tools
|
|
91
|
+
let requiredTools: string[];
|
|
92
|
+
if (workItem.requiredTools !== null && workItem.requiredTools !== undefined) {
|
|
93
|
+
requiredTools = sanitizeToolList(JSON.parse(workItem.requiredTools));
|
|
94
|
+
} else {
|
|
95
|
+
requiredTools = task.requiredTools
|
|
96
|
+
? sanitizeToolList(JSON.parse(task.requiredTools))
|
|
97
|
+
: getRegisteredToolNames();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Auto-approve all required tools for chat-initiated runs.
|
|
101
|
+
// The user explicitly asked to run the task, so we treat that as consent.
|
|
102
|
+
const approvedTools = requiredTools;
|
|
103
|
+
|
|
104
|
+
// Set status to running
|
|
105
|
+
updateWorkItem(workItemId, { status: 'running' });
|
|
106
|
+
|
|
107
|
+
const { getOrCreateSession, broadcast } = _callbacks;
|
|
108
|
+
|
|
109
|
+
// Broadcast the running state
|
|
110
|
+
broadcastWorkItemStatus(broadcast, workItemId);
|
|
111
|
+
broadcast({ type: 'tasks_changed' } as ServerMessage);
|
|
112
|
+
|
|
113
|
+
// Execute asynchronously
|
|
114
|
+
let session: Awaited<ReturnType<typeof getOrCreateSession>> | null = null;
|
|
115
|
+
void (async () => {
|
|
116
|
+
try {
|
|
117
|
+
const result = await runTask(
|
|
118
|
+
{ taskId: workItem.taskId, workingDir: process.cwd(), approvedTools },
|
|
119
|
+
async (conversationId, message, taskRunId) => {
|
|
120
|
+
if (!session) {
|
|
121
|
+
updateWorkItem(workItemId, { lastRunConversationId: conversationId });
|
|
122
|
+
session = await getOrCreateSession(conversationId);
|
|
123
|
+
|
|
124
|
+
broadcast({
|
|
125
|
+
type: 'task_run_thread_created',
|
|
126
|
+
conversationId,
|
|
127
|
+
workItemId,
|
|
128
|
+
title: workItem.title,
|
|
129
|
+
} as ServerMessage);
|
|
130
|
+
(session as unknown as { taskRunId?: string }).taskRunId = taskRunId;
|
|
131
|
+
(session as unknown as { headlessLock: boolean }).headlessLock = true;
|
|
132
|
+
}
|
|
133
|
+
await session.processMessage(message, [], (event) => {
|
|
134
|
+
broadcast(event);
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (session) {
|
|
140
|
+
(session as unknown as { headlessLock: boolean }).headlessLock = false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const current = getWorkItem(workItemId);
|
|
144
|
+
if (current?.status !== 'cancelled') {
|
|
145
|
+
const finalStatus: WorkItemStatus = result.status === 'completed' ? 'awaiting_review' : 'failed';
|
|
146
|
+
updateWorkItem(workItemId, {
|
|
147
|
+
status: finalStatus,
|
|
148
|
+
lastRunId: result.taskRunId,
|
|
149
|
+
lastRunConversationId: result.conversationId,
|
|
150
|
+
lastRunStatus: result.status,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
broadcastWorkItemStatus(broadcast, workItemId);
|
|
155
|
+
broadcast({ type: 'tasks_changed' } as ServerMessage);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (session) {
|
|
158
|
+
(session as unknown as { headlessLock: boolean }).headlessLock = false;
|
|
159
|
+
}
|
|
160
|
+
log.error({ err, workItemId }, 'work item background run failed');
|
|
161
|
+
updateWorkItem(workItemId, {
|
|
162
|
+
status: 'failed',
|
|
163
|
+
lastRunStatus: 'failed',
|
|
164
|
+
});
|
|
165
|
+
broadcastWorkItemStatus(broadcast, workItemId);
|
|
166
|
+
broadcast({ type: 'tasks_changed' } as ServerMessage);
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
|
|
170
|
+
return { success: true };
|
|
171
|
+
}
|
|
@@ -10,9 +10,10 @@ export type CommitMessageSource = 'llm' | 'deterministic';
|
|
|
10
10
|
export type LLMFallbackReason =
|
|
11
11
|
| 'disabled'
|
|
12
12
|
| 'missing_provider_api_key'
|
|
13
|
-
| 'provider_not_initialized'
|
|
14
13
|
| 'breaker_open'
|
|
15
14
|
| 'insufficient_budget'
|
|
15
|
+
| 'missing_fast_model'
|
|
16
|
+
| 'provider_not_initialized'
|
|
16
17
|
| 'timeout'
|
|
17
18
|
| 'provider_error'
|
|
18
19
|
| 'invalid_output';
|
|
@@ -103,17 +104,25 @@ export class ProviderCommitMessageGenerator {
|
|
|
103
104
|
const config = getConfig();
|
|
104
105
|
const llmConfig = config.workspaceGit.commitMessageLLM;
|
|
105
106
|
|
|
107
|
+
// ── Fallback check order (canonical) ──────────────────────────────
|
|
108
|
+
// 1. disabled
|
|
109
|
+
// 2. missing_provider_api_key (except keyless providers like ollama)
|
|
110
|
+
// 3. breaker_open
|
|
111
|
+
// 4. insufficient_budget
|
|
112
|
+
// 5. missing_fast_model
|
|
113
|
+
// 6. provider_not_initialized
|
|
114
|
+
// 7. call provider → timeout / provider_error / invalid_output
|
|
115
|
+
// ──────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
106
117
|
// Step 1: Feature gate
|
|
107
118
|
if (!llmConfig.enabled) {
|
|
108
119
|
return buildDeterministicResult(context, 'disabled');
|
|
109
120
|
}
|
|
110
|
-
|
|
111
|
-
// Step 2: Provider gate
|
|
112
121
|
if (!llmConfig.useConfiguredProvider) {
|
|
113
122
|
return buildDeterministicResult(context, 'disabled');
|
|
114
123
|
}
|
|
115
124
|
|
|
116
|
-
// Step 2
|
|
125
|
+
// Step 2: API key preflight (skip for providers that run without a key)
|
|
117
126
|
if (!KEYLESS_PROVIDERS.has(config.provider)) {
|
|
118
127
|
const providerApiKey = config.apiKeys[config.provider];
|
|
119
128
|
if (!providerApiKey || providerApiKey === '') {
|
|
@@ -143,7 +152,19 @@ export class ProviderCommitMessageGenerator {
|
|
|
143
152
|
}
|
|
144
153
|
}
|
|
145
154
|
|
|
146
|
-
// Step 5:
|
|
155
|
+
// Step 5: Fast model preflight — resolve before any provider call
|
|
156
|
+
const fastModel = llmConfig.providerFastModelOverrides[config.provider]
|
|
157
|
+
?? PROVIDER_DEFAULT_FAST_MODELS[config.provider];
|
|
158
|
+
|
|
159
|
+
if (!fastModel) {
|
|
160
|
+
log.debug(
|
|
161
|
+
{ provider: config.provider },
|
|
162
|
+
'No fast model resolvable for provider; falling back to deterministic',
|
|
163
|
+
);
|
|
164
|
+
return buildDeterministicResult(context, 'missing_fast_model');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Step 6 + 7: Call the provider
|
|
147
168
|
try {
|
|
148
169
|
const { getProvider } = await import('../providers/registry.js');
|
|
149
170
|
|
|
@@ -179,14 +200,6 @@ export class ProviderCommitMessageGenerator {
|
|
|
179
200
|
},
|
|
180
201
|
];
|
|
181
202
|
|
|
182
|
-
// Resolve fast model
|
|
183
|
-
const fastModel = llmConfig.providerFastModelOverrides[config.provider]
|
|
184
|
-
?? PROVIDER_DEFAULT_FAST_MODELS[config.provider];
|
|
185
|
-
if (!fastModel) {
|
|
186
|
-
log.debug({ provider: config.provider }, 'No default fast model for provider; falling back to deterministic');
|
|
187
|
-
return buildDeterministicResult(context, 'provider_error');
|
|
188
|
-
}
|
|
189
|
-
|
|
190
203
|
// AbortController with timeout
|
|
191
204
|
const ac = new AbortController();
|
|
192
205
|
const timer = setTimeout(() => ac.abort(), llmConfig.timeoutMs);
|
|
@@ -199,7 +212,11 @@ export class ProviderCommitMessageGenerator {
|
|
|
199
212
|
SYSTEM_PROMPT,
|
|
200
213
|
{
|
|
201
214
|
signal: ac.signal,
|
|
202
|
-
config: {
|
|
215
|
+
config: {
|
|
216
|
+
model: fastModel,
|
|
217
|
+
max_tokens: llmConfig.maxTokens,
|
|
218
|
+
temperature: llmConfig.temperature,
|
|
219
|
+
},
|
|
203
220
|
},
|
|
204
221
|
);
|
|
205
222
|
} catch (err: unknown) {
|
|
@@ -230,21 +247,20 @@ export class ProviderCommitMessageGenerator {
|
|
|
230
247
|
return buildDeterministicResult(context, 'invalid_output');
|
|
231
248
|
}
|
|
232
249
|
|
|
233
|
-
//
|
|
234
|
-
const
|
|
235
|
-
if (
|
|
250
|
+
// Cap subject line to 72 chars deterministically (no fallback, no breaker failure)
|
|
251
|
+
const lines = text.split('\n');
|
|
252
|
+
if (lines[0].length > 72) {
|
|
236
253
|
log.debug(
|
|
237
|
-
{
|
|
238
|
-
'LLM subject line
|
|
254
|
+
{ originalLength: lines[0].length },
|
|
255
|
+
'Capping LLM subject line to 72 chars',
|
|
239
256
|
);
|
|
240
|
-
|
|
241
|
-
return buildDeterministicResult(context, 'invalid_output');
|
|
257
|
+
lines[0] = lines[0].slice(0, 72);
|
|
242
258
|
}
|
|
259
|
+
const finalMessage = lines.join('\n');
|
|
243
260
|
|
|
244
261
|
this.recordSuccess();
|
|
245
|
-
return { message:
|
|
262
|
+
return { message: finalMessage, source: 'llm' };
|
|
246
263
|
} catch (err: unknown) {
|
|
247
|
-
// Step 6: Any error -> deterministic fallback
|
|
248
264
|
log.warn(
|
|
249
265
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
250
266
|
'Commit message LLM provider error; falling back to deterministic',
|
|
@@ -72,10 +72,14 @@ export async function commitTurnChanges(
|
|
|
72
72
|
if (!provider) {
|
|
73
73
|
// Guard: skip pre-check if deadline already elapsed to avoid unnecessary mutex contention
|
|
74
74
|
let preClean = false;
|
|
75
|
+
let candidateChangedFiles: string[] = [];
|
|
75
76
|
if (!deadlineMs || Date.now() < deadlineMs) {
|
|
76
77
|
try {
|
|
77
78
|
const preStatus = await gitService.getStatus();
|
|
78
79
|
preClean = preStatus.clean;
|
|
80
|
+
if (!preClean) {
|
|
81
|
+
candidateChangedFiles = [...new Set([...preStatus.staged, ...preStatus.modified, ...preStatus.untracked])];
|
|
82
|
+
}
|
|
79
83
|
} catch {
|
|
80
84
|
// If we can't determine status, assume dirty so we don't skip the commit
|
|
81
85
|
}
|
|
@@ -90,10 +94,10 @@ export async function commitTurnChanges(
|
|
|
90
94
|
trigger: 'turn',
|
|
91
95
|
sessionId,
|
|
92
96
|
turnNumber,
|
|
93
|
-
changedFiles:
|
|
97
|
+
changedFiles: candidateChangedFiles,
|
|
94
98
|
timestampMs: Date.now(),
|
|
95
99
|
},
|
|
96
|
-
{ deadlineMs, changedFiles:
|
|
100
|
+
{ deadlineMs, changedFiles: candidateChangedFiles },
|
|
97
101
|
);
|
|
98
102
|
commitMessageSource = result.source;
|
|
99
103
|
llmFallbackReason = result.reason;
|
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, mock, beforeEach } from 'bun:test';
|
|
2
|
-
import { mkdtempSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import * as net from 'node:net';
|
|
6
|
-
|
|
7
|
-
const testDir = mkdtempSync(join(tmpdir(), 'handlers-twilio-cfg-test-'));
|
|
8
|
-
|
|
9
|
-
let rawConfigStore: Record<string, unknown> = {};
|
|
10
|
-
const saveRawConfigCalls: Record<string, unknown>[] = [];
|
|
11
|
-
|
|
12
|
-
mock.module('../config/loader.js', () => ({
|
|
13
|
-
getConfig: () => ({}),
|
|
14
|
-
loadConfig: () => ({}),
|
|
15
|
-
loadRawConfig: () => ({ ...rawConfigStore }),
|
|
16
|
-
saveRawConfig: (cfg: Record<string, unknown>) => {
|
|
17
|
-
saveRawConfigCalls.push(cfg);
|
|
18
|
-
rawConfigStore = { ...cfg };
|
|
19
|
-
},
|
|
20
|
-
saveConfig: () => {},
|
|
21
|
-
invalidateConfigCache: () => {},
|
|
22
|
-
}));
|
|
23
|
-
|
|
24
|
-
mock.module('../util/platform.js', () => ({
|
|
25
|
-
getRootDir: () => testDir,
|
|
26
|
-
getDataDir: () => testDir,
|
|
27
|
-
getIpcBlobDir: () => join(testDir, 'ipc-blobs'),
|
|
28
|
-
isMacOS: () => process.platform === 'darwin',
|
|
29
|
-
isLinux: () => process.platform === 'linux',
|
|
30
|
-
isWindows: () => process.platform === 'win32',
|
|
31
|
-
getSocketPath: () => join(testDir, 'test.sock'),
|
|
32
|
-
getPidPath: () => join(testDir, 'test.pid'),
|
|
33
|
-
getDbPath: () => join(testDir, 'test.db'),
|
|
34
|
-
getLogPath: () => join(testDir, 'test.log'),
|
|
35
|
-
ensureDataDir: () => {},
|
|
36
|
-
}));
|
|
37
|
-
|
|
38
|
-
mock.module('../util/logger.js', () => ({
|
|
39
|
-
getLogger: () => ({
|
|
40
|
-
info: () => {},
|
|
41
|
-
warn: () => {},
|
|
42
|
-
error: () => {},
|
|
43
|
-
debug: () => {},
|
|
44
|
-
trace: () => {},
|
|
45
|
-
fatal: () => {},
|
|
46
|
-
child: () => ({
|
|
47
|
-
info: () => {},
|
|
48
|
-
warn: () => {},
|
|
49
|
-
error: () => {},
|
|
50
|
-
debug: () => {},
|
|
51
|
-
}),
|
|
52
|
-
}),
|
|
53
|
-
}));
|
|
54
|
-
|
|
55
|
-
mock.module('../memory/app-store.js', () => ({
|
|
56
|
-
queryAppRecords: () => [],
|
|
57
|
-
createAppRecord: () => {},
|
|
58
|
-
updateAppRecord: () => {},
|
|
59
|
-
deleteAppRecord: () => {},
|
|
60
|
-
listApps: () => [],
|
|
61
|
-
getApp: () => undefined,
|
|
62
|
-
createApp: () => {},
|
|
63
|
-
updateApp: () => {},
|
|
64
|
-
}));
|
|
65
|
-
|
|
66
|
-
mock.module('../slack/slack-webhook.js', () => ({
|
|
67
|
-
postToSlackWebhook: async () => {},
|
|
68
|
-
}));
|
|
69
|
-
|
|
70
|
-
import { handleMessage, type HandlerContext } from '../daemon/handlers.js';
|
|
71
|
-
import type {
|
|
72
|
-
TwilioWebhookConfigRequest,
|
|
73
|
-
ServerMessage,
|
|
74
|
-
} from '../daemon/ipc-contract.js';
|
|
75
|
-
|
|
76
|
-
function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
|
|
77
|
-
const sent: ServerMessage[] = [];
|
|
78
|
-
const ctx: HandlerContext = {
|
|
79
|
-
sessions: new Map(),
|
|
80
|
-
socketToSession: new Map(),
|
|
81
|
-
cuSessions: new Map(),
|
|
82
|
-
socketToCuSession: new Map(),
|
|
83
|
-
cuObservationParseSequence: new Map(),
|
|
84
|
-
socketSandboxOverride: new Map(),
|
|
85
|
-
sharedRequestTimestamps: [],
|
|
86
|
-
debounceTimers: new Map(),
|
|
87
|
-
suppressConfigReload: false,
|
|
88
|
-
setSuppressConfigReload: () => {},
|
|
89
|
-
updateConfigFingerprint: () => {},
|
|
90
|
-
send: (_socket, msg) => { sent.push(msg); },
|
|
91
|
-
broadcast: () => {},
|
|
92
|
-
clearAllSessions: () => 0,
|
|
93
|
-
getOrCreateSession: () => { throw new Error('not implemented'); },
|
|
94
|
-
touchSession: () => {},
|
|
95
|
-
};
|
|
96
|
-
return { ctx, sent };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
describe('Twilio webhook config handler', () => {
|
|
100
|
-
beforeEach(() => {
|
|
101
|
-
rawConfigStore = {};
|
|
102
|
-
saveRawConfigCalls.length = 0;
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test('get returns empty string when no config set', () => {
|
|
106
|
-
rawConfigStore = {};
|
|
107
|
-
|
|
108
|
-
const msg: TwilioWebhookConfigRequest = {
|
|
109
|
-
type: 'twilio_webhook_config',
|
|
110
|
-
action: 'get',
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const { ctx, sent } = createTestContext();
|
|
114
|
-
handleMessage(msg, {} as net.Socket, ctx);
|
|
115
|
-
|
|
116
|
-
expect(sent).toHaveLength(1);
|
|
117
|
-
const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
|
|
118
|
-
expect(res.type).toBe('twilio_webhook_config_response');
|
|
119
|
-
expect(res.success).toBe(true);
|
|
120
|
-
expect(res.webhookBaseUrl).toBe('');
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test('set persists value and returns it', () => {
|
|
124
|
-
rawConfigStore = {};
|
|
125
|
-
|
|
126
|
-
const msg: TwilioWebhookConfigRequest = {
|
|
127
|
-
type: 'twilio_webhook_config',
|
|
128
|
-
action: 'set',
|
|
129
|
-
webhookBaseUrl: 'https://example.com/twilio',
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const { ctx, sent } = createTestContext();
|
|
133
|
-
handleMessage(msg, {} as net.Socket, ctx);
|
|
134
|
-
|
|
135
|
-
expect(sent).toHaveLength(1);
|
|
136
|
-
const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
|
|
137
|
-
expect(res.type).toBe('twilio_webhook_config_response');
|
|
138
|
-
expect(res.success).toBe(true);
|
|
139
|
-
expect(res.webhookBaseUrl).toBe('https://example.com/twilio');
|
|
140
|
-
|
|
141
|
-
expect(saveRawConfigCalls).toHaveLength(1);
|
|
142
|
-
const saved = saveRawConfigCalls[0] as { calls?: { webhookBaseUrl?: string } };
|
|
143
|
-
expect(saved.calls?.webhookBaseUrl).toBe('https://example.com/twilio');
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test('set normalizes trailing slashes', () => {
|
|
147
|
-
rawConfigStore = {};
|
|
148
|
-
|
|
149
|
-
const msg: TwilioWebhookConfigRequest = {
|
|
150
|
-
type: 'twilio_webhook_config',
|
|
151
|
-
action: 'set',
|
|
152
|
-
webhookBaseUrl: 'https://example.com/twilio///',
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
const { ctx, sent } = createTestContext();
|
|
156
|
-
handleMessage(msg, {} as net.Socket, ctx);
|
|
157
|
-
|
|
158
|
-
expect(sent).toHaveLength(1);
|
|
159
|
-
const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
|
|
160
|
-
expect(res.webhookBaseUrl).toBe('https://example.com/twilio');
|
|
161
|
-
|
|
162
|
-
const saved = saveRawConfigCalls[0] as { calls?: { webhookBaseUrl?: string } };
|
|
163
|
-
expect(saved.calls?.webhookBaseUrl).toBe('https://example.com/twilio');
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
test('set treats empty string as unset', () => {
|
|
167
|
-
rawConfigStore = { calls: { webhookBaseUrl: 'https://example.com/twilio' } };
|
|
168
|
-
saveRawConfigCalls.length = 0;
|
|
169
|
-
|
|
170
|
-
const msg: TwilioWebhookConfigRequest = {
|
|
171
|
-
type: 'twilio_webhook_config',
|
|
172
|
-
action: 'set',
|
|
173
|
-
webhookBaseUrl: '',
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
const { ctx, sent } = createTestContext();
|
|
177
|
-
handleMessage(msg, {} as net.Socket, ctx);
|
|
178
|
-
|
|
179
|
-
expect(sent).toHaveLength(1);
|
|
180
|
-
const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
|
|
181
|
-
expect(res.success).toBe(true);
|
|
182
|
-
expect(res.webhookBaseUrl).toBe('');
|
|
183
|
-
|
|
184
|
-
const saved = saveRawConfigCalls[0] as { calls?: { webhookBaseUrl?: string } };
|
|
185
|
-
expect(saved.calls?.webhookBaseUrl).toBeUndefined();
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test('get after set roundtrip works', () => {
|
|
189
|
-
rawConfigStore = {};
|
|
190
|
-
|
|
191
|
-
// Set
|
|
192
|
-
const setMsg: TwilioWebhookConfigRequest = {
|
|
193
|
-
type: 'twilio_webhook_config',
|
|
194
|
-
action: 'set',
|
|
195
|
-
webhookBaseUrl: 'https://my-server.ngrok.io',
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
const { ctx: setCtx, sent: setSent } = createTestContext();
|
|
199
|
-
handleMessage(setMsg, {} as net.Socket, setCtx);
|
|
200
|
-
|
|
201
|
-
expect(setSent).toHaveLength(1);
|
|
202
|
-
const setRes = setSent[0] as { type: string; webhookBaseUrl: string; success: boolean };
|
|
203
|
-
expect(setRes.success).toBe(true);
|
|
204
|
-
expect(setRes.webhookBaseUrl).toBe('https://my-server.ngrok.io');
|
|
205
|
-
|
|
206
|
-
// Get (rawConfigStore was updated by the mock saveRawConfig)
|
|
207
|
-
const getMsg: TwilioWebhookConfigRequest = {
|
|
208
|
-
type: 'twilio_webhook_config',
|
|
209
|
-
action: 'get',
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
const { ctx: getCtx, sent: getSent } = createTestContext();
|
|
213
|
-
handleMessage(getMsg, {} as net.Socket, getCtx);
|
|
214
|
-
|
|
215
|
-
expect(getSent).toHaveLength(1);
|
|
216
|
-
const getRes = getSent[0] as { type: string; webhookBaseUrl: string; success: boolean };
|
|
217
|
-
expect(getRes.type).toBe('twilio_webhook_config_response');
|
|
218
|
-
expect(getRes.success).toBe(true);
|
|
219
|
-
expect(getRes.webhookBaseUrl).toBe('https://my-server.ngrok.io');
|
|
220
|
-
});
|
|
221
|
-
});
|