vellum 0.2.8 → 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 +2 -2
- package/package.json +3 -2
- 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 +91 -11
- package/src/__tests__/ingress-url-consistency.test.ts +214 -0
- package/src/__tests__/ipc-snapshot.test.ts +17 -16
- package/src/__tests__/oauth2-gateway-transport.test.ts +7 -1
- package/src/__tests__/public-ingress-urls.test.ts +50 -34
- package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
- package/src/__tests__/runtime-events-sse.test.ts +162 -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 +2 -3
- 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 +1 -2
- package/src/config/schema.ts +2 -6
- 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/handlers/config.ts +33 -50
- 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/ipc-contract-inventory.json +4 -4
- package/src/daemon/ipc-contract.ts +25 -21
- package/src/daemon/lifecycle.ts +9 -4
- package/src/daemon/server.ts +7 -0
- package/src/daemon/session-tool-setup.ts +1 -1
- package/src/inbound/public-ingress-urls.ts +36 -30
- package/src/memory/db.ts +132 -5
- package/src/memory/llm-usage-store.ts +0 -1
- package/src/memory/runs-store.ts +51 -3
- package/src/memory/schema.ts +2 -2
- package/src/runtime/gateway-client.ts +7 -1
- package/src/runtime/http-server.ts +95 -10
- 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 +10 -0
- package/src/security/oauth2.ts +41 -7
- package/src/subagent/manager.ts +3 -1
- 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/__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 -47
|
@@ -42,6 +42,7 @@ export async function handleDeleteConversation(req: Request): Promise<Response>
|
|
|
42
42
|
export async function handleChannelInbound(
|
|
43
43
|
req: Request,
|
|
44
44
|
processMessage?: MessageProcessor,
|
|
45
|
+
bearerToken?: string,
|
|
45
46
|
): Promise<Response> {
|
|
46
47
|
const body = await req.json() as {
|
|
47
48
|
sourceChannel?: string;
|
|
@@ -229,6 +230,7 @@ export async function handleChannelInbound(
|
|
|
229
230
|
metadataHints,
|
|
230
231
|
metadataUxBrief,
|
|
231
232
|
replyCallbackUrl,
|
|
233
|
+
bearerToken,
|
|
232
234
|
});
|
|
233
235
|
}
|
|
234
236
|
|
|
@@ -250,6 +252,7 @@ interface BackgroundProcessingParams {
|
|
|
250
252
|
metadataHints: string[];
|
|
251
253
|
metadataUxBrief?: string;
|
|
252
254
|
replyCallbackUrl?: string;
|
|
255
|
+
bearerToken?: string;
|
|
253
256
|
}
|
|
254
257
|
|
|
255
258
|
function processChannelMessageInBackground(params: BackgroundProcessingParams): void {
|
|
@@ -264,6 +267,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
|
|
|
264
267
|
metadataHints,
|
|
265
268
|
metadataUxBrief,
|
|
266
269
|
replyCallbackUrl,
|
|
270
|
+
bearerToken,
|
|
267
271
|
} = params;
|
|
268
272
|
|
|
269
273
|
(async () => {
|
|
@@ -285,7 +289,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
|
|
|
285
289
|
channelDeliveryStore.markProcessed(eventId);
|
|
286
290
|
|
|
287
291
|
if (replyCallbackUrl) {
|
|
288
|
-
await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl);
|
|
292
|
+
await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
|
|
289
293
|
}
|
|
290
294
|
} catch (err) {
|
|
291
295
|
log.error({ err, conversationId }, 'Background channel message processing failed');
|
|
@@ -298,6 +302,7 @@ async function deliverReplyViaCallback(
|
|
|
298
302
|
conversationId: string,
|
|
299
303
|
externalChatId: string,
|
|
300
304
|
callbackUrl: string,
|
|
305
|
+
bearerToken?: string,
|
|
301
306
|
): Promise<void> {
|
|
302
307
|
const msgs = conversationStore.getMessages(conversationId);
|
|
303
308
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
@@ -320,7 +325,7 @@ async function deliverReplyViaCallback(
|
|
|
320
325
|
chatId: externalChatId,
|
|
321
326
|
text: rendered.text || undefined,
|
|
322
327
|
attachments: replyAttachments.length > 0 ? replyAttachments : undefined,
|
|
323
|
-
});
|
|
328
|
+
}, bearerToken);
|
|
324
329
|
}
|
|
325
330
|
break;
|
|
326
331
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route handler for the assistant-events SSE endpoint.
|
|
3
|
+
*
|
|
4
|
+
* GET /v1/events?conversationKey=...
|
|
5
|
+
*
|
|
6
|
+
* Auth is enforced by RuntimeHttpServer before this handler is called.
|
|
7
|
+
* Subscribers receive all assistant events scoped to the given conversation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getOrCreateConversation } from '../../memory/conversation-key-store.js';
|
|
11
|
+
import { assistantEventHub } from '../assistant-event-hub.js';
|
|
12
|
+
import { formatSseFrame } from '../assistant-event.js';
|
|
13
|
+
import type { AssistantEventSubscription } from '../assistant-event-hub.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stream assistant events as Server-Sent Events for a specific conversation.
|
|
17
|
+
*
|
|
18
|
+
* Query params:
|
|
19
|
+
* conversationKey — required; scopes the stream to one conversation.
|
|
20
|
+
*/
|
|
21
|
+
export function handleSubscribeAssistantEvents(
|
|
22
|
+
req: Request,
|
|
23
|
+
url: URL,
|
|
24
|
+
): Response {
|
|
25
|
+
const conversationKey = url.searchParams.get('conversationKey');
|
|
26
|
+
if (!conversationKey) {
|
|
27
|
+
return Response.json({ error: 'conversationKey is required' }, { status: 400 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const mapping = getOrCreateConversation(conversationKey);
|
|
31
|
+
const encoder = new TextEncoder();
|
|
32
|
+
let sub: AssistantEventSubscription | null = null;
|
|
33
|
+
|
|
34
|
+
// Allow up to 16 queued frames before treating the consumer as stalled.
|
|
35
|
+
// This absorbs normal token-stream bursts without prematurely closing the
|
|
36
|
+
// connection, while still shedding genuinely slow clients.
|
|
37
|
+
const stream = new ReadableStream({
|
|
38
|
+
start(controller) {
|
|
39
|
+
// 'self' is the assistantId that RunOrchestrator assigns to all HTTP-run events
|
|
40
|
+
// (see buildAssistantEvent('self', ...) in run-orchestrator.ts). This endpoint
|
|
41
|
+
// is part of the HTTP runtime API, so only HTTP-run events are relevant here.
|
|
42
|
+
// IPC/daemon events use a different assistantId ('default') and reach desktop
|
|
43
|
+
// clients through a separate channel — they are intentionally excluded.
|
|
44
|
+
sub = assistantEventHub.subscribe(
|
|
45
|
+
{ assistantId: 'self', sessionId: mapping.conversationId },
|
|
46
|
+
(event) => {
|
|
47
|
+
try {
|
|
48
|
+
// Shed stalled consumers: desiredSize <= 0 means the 16-event buffer
|
|
49
|
+
// is full and the client isn't draining it.
|
|
50
|
+
if (controller.desiredSize !== null && controller.desiredSize <= 0) {
|
|
51
|
+
sub?.dispose();
|
|
52
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
controller.enqueue(encoder.encode(formatSseFrame(event)));
|
|
56
|
+
} catch {
|
|
57
|
+
sub?.dispose();
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
req.signal.addEventListener('abort', () => {
|
|
63
|
+
sub?.dispose();
|
|
64
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
65
|
+
}, { once: true });
|
|
66
|
+
},
|
|
67
|
+
cancel() {
|
|
68
|
+
sub?.dispose();
|
|
69
|
+
},
|
|
70
|
+
}, new CountQueuingStrategy({ highWaterMark: 16 }));
|
|
71
|
+
|
|
72
|
+
return new Response(stream, {
|
|
73
|
+
headers: {
|
|
74
|
+
'Content-Type': 'text/event-stream',
|
|
75
|
+
'Cache-Control': 'no-cache',
|
|
76
|
+
'Connection': 'keep-alive',
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -88,6 +88,7 @@ export function handleGetRun(
|
|
|
88
88
|
status: run.status,
|
|
89
89
|
messageId: run.messageId,
|
|
90
90
|
pendingConfirmation: run.pendingConfirmation,
|
|
91
|
+
pendingSecret: run.pendingSecret,
|
|
91
92
|
error: run.error,
|
|
92
93
|
createdAt: new Date(run.createdAt).toISOString(),
|
|
93
94
|
updatedAt: new Date(run.updatedAt).toISOString(),
|
|
@@ -217,3 +218,45 @@ export async function handleAddTrustRule(
|
|
|
217
218
|
return Response.json({ error: 'Failed to add trust rule' }, { status: 500 });
|
|
218
219
|
}
|
|
219
220
|
}
|
|
221
|
+
|
|
222
|
+
export async function handleRunSecret(
|
|
223
|
+
runId: string,
|
|
224
|
+
req: Request,
|
|
225
|
+
runOrchestrator: RunOrchestrator,
|
|
226
|
+
): Promise<Response> {
|
|
227
|
+
const run = runOrchestrator.getRun(runId);
|
|
228
|
+
if (!run) {
|
|
229
|
+
return Response.json({ error: 'Run not found' }, { status: 404 });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const body = await req.json() as {
|
|
233
|
+
value?: string;
|
|
234
|
+
delivery?: string;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const { value, delivery } = body;
|
|
238
|
+
|
|
239
|
+
if (delivery !== undefined && delivery !== 'store' && delivery !== 'transient_send') {
|
|
240
|
+
return Response.json(
|
|
241
|
+
{ error: 'delivery must be "store" or "transient_send"' },
|
|
242
|
+
{ status: 400 },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const result = runOrchestrator.submitSecret(
|
|
247
|
+
runId,
|
|
248
|
+
value,
|
|
249
|
+
delivery as 'store' | 'transient_send' | undefined,
|
|
250
|
+
);
|
|
251
|
+
if (result === 'run_not_found') {
|
|
252
|
+
return Response.json({ error: 'Run not found' }, { status: 404 });
|
|
253
|
+
}
|
|
254
|
+
if (result === 'no_pending_secret') {
|
|
255
|
+
return Response.json(
|
|
256
|
+
{ error: 'No secret pending for this run' },
|
|
257
|
+
{ status: 409 },
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return Response.json({ accepted: true });
|
|
262
|
+
}
|
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A "run" wraps a single agent-loop execution, tracking its state through:
|
|
5
5
|
* running → needs_confirmation → running → completed | failed
|
|
6
|
+
* running → needs_secret → running → completed | failed
|
|
6
7
|
*
|
|
7
8
|
* When a tool needs permission, the orchestrator intercepts the
|
|
8
9
|
* confirmation_request from the session's prompter and records it in
|
|
9
|
-
* the run store.
|
|
10
|
-
*
|
|
10
|
+
* the run store. Similarly, when a tool needs a secret (e.g.
|
|
11
|
+
* credential_store prompt), the orchestrator intercepts the
|
|
12
|
+
* secret_request and records it. The client can then poll the run
|
|
13
|
+
* status and submit a decision or secret via the respective endpoints.
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
import * as runsStore from '../memory/runs-store.js';
|
|
@@ -113,11 +116,10 @@ export class RunOrchestrator {
|
|
|
113
116
|
};
|
|
114
117
|
|
|
115
118
|
|
|
116
|
-
// Hook into session to intercept confirmation_request events.
|
|
117
|
-
// When the prompter sends
|
|
118
|
-
//
|
|
119
|
-
// Do NOT set hasNoClient — run sessions have a client (the HTTP caller)
|
|
120
|
-
// and confirmations are handled via the /runs/:id/decision endpoint.
|
|
119
|
+
// Hook into session to intercept confirmation_request and secret_request events.
|
|
120
|
+
// When the prompter sends one of these, we record it in the run store so
|
|
121
|
+
// the client can poll and submit a decision/secret via the respective endpoint.
|
|
122
|
+
// Do NOT set hasNoClient — run sessions have a client (the HTTP caller).
|
|
121
123
|
let lastError: string | null = null;
|
|
122
124
|
session.updateClient((msg: ServerMessage) => {
|
|
123
125
|
if (msg.type === 'confirmation_request') {
|
|
@@ -138,6 +140,21 @@ export class RunOrchestrator {
|
|
|
138
140
|
prompterRequestId: msg.requestId,
|
|
139
141
|
session,
|
|
140
142
|
});
|
|
143
|
+
} else if (msg.type === 'secret_request') {
|
|
144
|
+
runsStore.setRunSecret(run.id, {
|
|
145
|
+
requestId: msg.requestId,
|
|
146
|
+
service: msg.service,
|
|
147
|
+
field: msg.field,
|
|
148
|
+
label: msg.label,
|
|
149
|
+
description: msg.description,
|
|
150
|
+
placeholder: msg.placeholder,
|
|
151
|
+
purpose: msg.purpose,
|
|
152
|
+
allowOneTimeSend: msg.allowOneTimeSend,
|
|
153
|
+
});
|
|
154
|
+
this.pending.set(run.id, {
|
|
155
|
+
prompterRequestId: msg.requestId,
|
|
156
|
+
session,
|
|
157
|
+
});
|
|
141
158
|
}
|
|
142
159
|
// Mirror every outbound message to the assistant-events hub so SSE
|
|
143
160
|
// subscribers receive the same payload parity as IPC clients.
|
|
@@ -236,4 +253,44 @@ export class RunOrchestrator {
|
|
|
236
253
|
// the client doesn't mistakenly treat the decision as accepted.
|
|
237
254
|
return 'no_pending_decision';
|
|
238
255
|
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Submit a secret value for a pending secret request.
|
|
259
|
+
*
|
|
260
|
+
* Returns:
|
|
261
|
+
* - `'applied'` – secret was forwarded to the session
|
|
262
|
+
* - `'run_not_found'` – no run exists with the given ID
|
|
263
|
+
* - `'no_pending_secret'` – run exists but is not awaiting a secret
|
|
264
|
+
*/
|
|
265
|
+
submitSecret(
|
|
266
|
+
runId: string,
|
|
267
|
+
value?: string,
|
|
268
|
+
delivery?: 'store' | 'transient_send',
|
|
269
|
+
): 'applied' | 'run_not_found' | 'no_pending_secret' {
|
|
270
|
+
const pendingState = this.pending.get(runId);
|
|
271
|
+
if (pendingState) {
|
|
272
|
+
runsStore.clearRunSecret(runId);
|
|
273
|
+
pendingState.session.handleSecretResponse(
|
|
274
|
+
pendingState.prompterRequestId,
|
|
275
|
+
value,
|
|
276
|
+
delivery,
|
|
277
|
+
);
|
|
278
|
+
this.pending.delete(runId);
|
|
279
|
+
return 'applied';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const run = runsStore.getRun(runId);
|
|
283
|
+
if (!run) return 'run_not_found';
|
|
284
|
+
|
|
285
|
+
if (run.status === 'needs_secret') {
|
|
286
|
+
runsStore.failRun(runId, 'Secret prompter timed out (no active handler)');
|
|
287
|
+
return 'applied';
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (run.status === 'completed' || run.status === 'failed') {
|
|
291
|
+
return 'applied';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return 'no_pending_secret';
|
|
295
|
+
}
|
|
239
296
|
}
|
|
@@ -19,6 +19,15 @@ export function registerPendingCallback(
|
|
|
19
19
|
reject: (error: Error) => void,
|
|
20
20
|
ttlMs = DEFAULT_TTL_MS,
|
|
21
21
|
): void {
|
|
22
|
+
// Clear any existing entry for this state to prevent timer leaks and
|
|
23
|
+
// cross-callback timeouts when the same state is registered twice.
|
|
24
|
+
const existing = pendingCallbacks.get(state);
|
|
25
|
+
if (existing) {
|
|
26
|
+
clearTimeout(existing.timer);
|
|
27
|
+
existing.reject(new Error('OAuth callback superseded by new registration'));
|
|
28
|
+
pendingCallbacks.delete(state);
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
const timer = setTimeout(() => {
|
|
23
32
|
const entry = pendingCallbacks.get(state);
|
|
24
33
|
if (entry) {
|
|
@@ -51,6 +60,7 @@ export function consumeCallbackError(state: string, error: string): boolean {
|
|
|
51
60
|
export function clearAllCallbacks(): void {
|
|
52
61
|
for (const entry of pendingCallbacks.values()) {
|
|
53
62
|
clearTimeout(entry.timer);
|
|
63
|
+
entry.reject(new Error('OAuth callback registry cleared'));
|
|
54
64
|
}
|
|
55
65
|
pendingCallbacks.clear();
|
|
56
66
|
}
|
package/src/security/oauth2.ts
CHANGED
|
@@ -144,19 +144,18 @@ async function exchangeCodeForTokens(
|
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
146
|
* Determine which callback transport to use when not explicitly specified.
|
|
147
|
-
* Uses gateway if
|
|
147
|
+
* Uses gateway if a public base URL is configured (ingress.publicBaseUrl or
|
|
148
|
+
* INGRESS_PUBLIC_BASE_URL), otherwise loopback.
|
|
148
149
|
*/
|
|
149
150
|
function detectTransport(): 'loopback' | 'gateway' {
|
|
150
151
|
try {
|
|
151
|
-
// Dynamic import avoided — loadConfig is synchronous and already used elsewhere.
|
|
152
152
|
const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
|
|
153
|
+
const { getPublicBaseUrl } = require('../inbound/public-ingress-urls.js') as typeof import('../inbound/public-ingress-urls.js');
|
|
153
154
|
const appConfig = loadConfig();
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
155
|
+
getPublicBaseUrl(appConfig); // throws if no public URL configured
|
|
156
|
+
return 'gateway';
|
|
157
157
|
} catch {
|
|
158
|
-
|
|
159
|
-
log.debug('Config not available for transport auto-detection, defaulting to loopback');
|
|
158
|
+
log.debug('No public base URL configured for transport auto-detection, defaulting to loopback');
|
|
160
159
|
}
|
|
161
160
|
return 'loopback';
|
|
162
161
|
}
|
|
@@ -319,6 +318,41 @@ export async function startOAuth2Flow(
|
|
|
319
318
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
320
319
|
const state = generateState();
|
|
321
320
|
|
|
321
|
+
// In gateway_only mode, enforce gateway transport and require a public ingress URL
|
|
322
|
+
let ingressMode: string | undefined;
|
|
323
|
+
try {
|
|
324
|
+
const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
|
|
325
|
+
ingressMode = loadConfig().ingress.mode;
|
|
326
|
+
} catch {
|
|
327
|
+
// Fail closed: if config can't be loaded (e.g., malformed config.json), default to the
|
|
328
|
+
// most restrictive mode to prevent loopback fallback from creating a fail-open path.
|
|
329
|
+
log.warn('Failed to load config for OAuth ingress mode detection; defaulting to gateway_only (fail closed)');
|
|
330
|
+
ingressMode = 'gateway_only';
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (ingressMode === 'gateway_only') {
|
|
334
|
+
// Verify a public ingress URL is configured; fail fast with actionable error if not
|
|
335
|
+
let hasPublicUrl = false;
|
|
336
|
+
try {
|
|
337
|
+
const { loadConfig } = require('../config/loader.js') as typeof import('../config/loader.js');
|
|
338
|
+
const { getPublicBaseUrl } = require('../inbound/public-ingress-urls.js') as typeof import('../inbound/public-ingress-urls.js');
|
|
339
|
+
getPublicBaseUrl(loadConfig());
|
|
340
|
+
hasPublicUrl = true;
|
|
341
|
+
} catch {
|
|
342
|
+
// No public URL configured
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!hasPublicUrl) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
'OAuth requires a public ingress URL in gateway-only mode. Set ingress.publicBaseUrl or INGRESS_PUBLIC_BASE_URL so OAuth callbacks can route through the gateway.',
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// In gateway_only mode, always use gateway transport — never fall back to loopback
|
|
352
|
+
log.debug({ transport: 'gateway' }, 'OAuth2 flow starting (gateway_only mode)');
|
|
353
|
+
return runGatewayFlow(config, callbacks, codeVerifier, codeChallenge, state);
|
|
354
|
+
}
|
|
355
|
+
|
|
322
356
|
const transport = options?.callbackTransport ?? detectTransport();
|
|
323
357
|
log.debug({ transport }, 'OAuth2 flow starting');
|
|
324
358
|
|
package/src/subagent/manager.ts
CHANGED
|
@@ -57,6 +57,7 @@ export interface SubagentNotificationInfo {
|
|
|
57
57
|
label: string;
|
|
58
58
|
status: 'completed' | 'failed' | 'aborted';
|
|
59
59
|
error?: string;
|
|
60
|
+
conversationId?: string;
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
export type ParentNotifyCallback = (
|
|
@@ -299,7 +300,7 @@ export class SubagentManager {
|
|
|
299
300
|
managed.state.config.parentSessionId,
|
|
300
301
|
message,
|
|
301
302
|
managed.parentSendToClient,
|
|
302
|
-
{ subagentId, label, status: 'aborted' },
|
|
303
|
+
{ subagentId, label, status: 'aborted', conversationId: managed.state.conversationId },
|
|
303
304
|
);
|
|
304
305
|
} catch (err) {
|
|
305
306
|
log.error({ subagentId, err }, 'Failed to notify parent about abort');
|
|
@@ -497,6 +498,7 @@ export class SubagentManager {
|
|
|
497
498
|
subagentId: config.id,
|
|
498
499
|
label: config.label,
|
|
499
500
|
status: outcome,
|
|
501
|
+
conversationId: managed.state.conversationId,
|
|
500
502
|
...(outcome === 'failed' ? { error: managed.state.error ?? 'Unknown error' } : {}),
|
|
501
503
|
};
|
|
502
504
|
|
|
@@ -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
|
+
}
|