@vellumai/assistant 0.9.0-dev.202606181513.fd39213 → 0.9.0-dev.202606181735.ae6e1d8
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/openapi.yaml +82 -0
- package/package.json +1 -1
- package/src/__tests__/plugin-tool-contribution.test.ts +6 -3
- package/src/api/events/workflow-completed.ts +53 -0
- package/src/api/events/workflow-leaf-finished.ts +38 -0
- package/src/api/events/workflow-leaf-started.ts +35 -0
- package/src/api/events/workflow-progress.ts +32 -0
- package/src/api/events/workflow-started.ts +31 -0
- package/src/api/index.ts +38 -0
- package/src/api/responses/workflow-journal.ts +53 -0
- package/src/daemon/message-types/workflows.ts +83 -1
- package/src/memory/db-init.ts +2 -0
- package/src/memory/migrations/293-workflow-journal-leaf-tokens.ts +32 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +43 -0
- package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +134 -0
- package/src/plugins/defaults/advisor/__tests__/consult.test.ts +147 -0
- package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +138 -0
- package/src/plugins/defaults/advisor/__tests__/transcript.test.ts +147 -0
- package/src/plugins/defaults/advisor/advisor-state-store.ts +94 -0
- package/src/plugins/defaults/advisor/config.ts +26 -0
- package/src/plugins/defaults/advisor/consult.ts +76 -0
- package/src/plugins/defaults/advisor/hooks/post-model-call.ts +34 -0
- package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +24 -0
- package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +19 -0
- package/src/plugins/defaults/advisor/package.json +14 -0
- package/src/plugins/defaults/advisor/steering.ts +51 -0
- package/src/plugins/defaults/advisor/tools/advisor.ts +52 -0
- package/src/plugins/defaults/advisor/transcript.ts +76 -0
- package/src/plugins/defaults/index.ts +35 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +3 -3
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -1
- package/src/runtime/routes/workflow-routes.test.ts +225 -1
- package/src/runtime/routes/workflow-routes.ts +131 -1
- package/src/tools/workflows/run-workflow.ts +1 -0
- package/src/workflows/engine.test.ts +175 -1
- package/src/workflows/engine.ts +82 -0
- package/src/workflows/journal-store.test.ts +70 -0
- package/src/workflows/journal-store.ts +18 -3
- package/src/workflows/run-manager.test.ts +171 -3
- package/src/workflows/run-manager.ts +63 -0
package/src/workflows/engine.ts
CHANGED
|
@@ -73,6 +73,30 @@ export type WorkflowProgressEvent =
|
|
|
73
73
|
| { type: "phase"; title: string }
|
|
74
74
|
| { type: "log"; message: string };
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* An observe-only lifecycle event for a single leaf — the source of a live leaf
|
|
78
|
+
* tree. Emitted once when a leaf STARTS (after the cap check + spawn increment)
|
|
79
|
+
* and once when it FINISHES (completed or failed). A journal-replayed leaf and
|
|
80
|
+
* an aborted in-flight leaf emit neither.
|
|
81
|
+
*/
|
|
82
|
+
export type WorkflowLeafEvent =
|
|
83
|
+
| {
|
|
84
|
+
type: "leaf_started";
|
|
85
|
+
seq: number;
|
|
86
|
+
label?: string;
|
|
87
|
+
phase?: string;
|
|
88
|
+
promptSummary: string;
|
|
89
|
+
}
|
|
90
|
+
| {
|
|
91
|
+
type: "leaf_finished";
|
|
92
|
+
seq: number;
|
|
93
|
+
status: "completed" | "failed";
|
|
94
|
+
label?: string;
|
|
95
|
+
inputTokens: number;
|
|
96
|
+
outputTokens: number;
|
|
97
|
+
resultSummary: string;
|
|
98
|
+
};
|
|
99
|
+
|
|
76
100
|
/** The journal-store surface the engine depends on (injectable for tests). */
|
|
77
101
|
export interface WorkflowJournal {
|
|
78
102
|
appendJournalEntry: typeof JournalStore.appendJournalEntry;
|
|
@@ -108,6 +132,13 @@ export interface ExecuteWorkflowOptions {
|
|
|
108
132
|
trustContext: TrustContext;
|
|
109
133
|
/** Receives `phase`/`log` progress events from the script. */
|
|
110
134
|
onProgress?: (event: WorkflowProgressEvent) => void;
|
|
135
|
+
/**
|
|
136
|
+
* Observe-only per-leaf lifecycle hook. Emits once on START (after the cap
|
|
137
|
+
* check + spawn increment; NEVER on a journal-replay short-circuit) and once
|
|
138
|
+
* on FINISH (completed or failed). An aborted in-flight leaf emits NEITHER a
|
|
139
|
+
* finish nor a spurious failed.
|
|
140
|
+
*/
|
|
141
|
+
onLeaf?: (event: WorkflowLeafEvent) => void;
|
|
111
142
|
/** Cooperative cancellation for the whole run. */
|
|
112
143
|
signal?: AbortSignal;
|
|
113
144
|
}
|
|
@@ -451,6 +482,26 @@ function skipRegexLiteral(source: string, start: number): number {
|
|
|
451
482
|
return source.length - 1;
|
|
452
483
|
}
|
|
453
484
|
|
|
485
|
+
/**
|
|
486
|
+
* A bounded string summary of a leaf prompt or result, for an observe-only
|
|
487
|
+
* {@link WorkflowLeafEvent}. Strings pass through; everything else is
|
|
488
|
+
* JSON-serialized (falling back to `String(value)` if it cannot be). Truncated
|
|
489
|
+
* to `max` chars with a trailing ellipsis.
|
|
490
|
+
*/
|
|
491
|
+
function summarizeForLeafEvent(value: unknown, max = 200): string {
|
|
492
|
+
let text: string;
|
|
493
|
+
if (typeof value === "string") {
|
|
494
|
+
text = value;
|
|
495
|
+
} else {
|
|
496
|
+
try {
|
|
497
|
+
text = JSON.stringify(value);
|
|
498
|
+
} catch {
|
|
499
|
+
text = String(value);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
503
|
+
}
|
|
504
|
+
|
|
454
505
|
function callHashOf(prompt: string, opts: LeafCallOptions): string {
|
|
455
506
|
return createHash("sha256")
|
|
456
507
|
.update(deterministicStringify({ prompt, opts }))
|
|
@@ -542,6 +593,7 @@ export async function executeWorkflow(
|
|
|
542
593
|
leafRunner,
|
|
543
594
|
trustContext,
|
|
544
595
|
onProgress,
|
|
596
|
+
onLeaf,
|
|
545
597
|
signal,
|
|
546
598
|
} = opts;
|
|
547
599
|
|
|
@@ -630,6 +682,14 @@ export async function executeWorkflow(
|
|
|
630
682
|
}
|
|
631
683
|
agentsSpawned += 1;
|
|
632
684
|
|
|
685
|
+
onLeaf?.({
|
|
686
|
+
type: "leaf_started",
|
|
687
|
+
seq,
|
|
688
|
+
...(leafOpts.label !== undefined ? { label: leafOpts.label } : {}),
|
|
689
|
+
...(leafOpts.phase !== undefined ? { phase: leafOpts.phase } : {}),
|
|
690
|
+
promptSummary: summarizeForLeafEvent(prompt),
|
|
691
|
+
});
|
|
692
|
+
|
|
633
693
|
try {
|
|
634
694
|
// A leaf is EITHER a schema leaf (structured output via forced
|
|
635
695
|
// tool-choice — `schema` set, no tools) OR a tool leaf (free-form with
|
|
@@ -666,6 +726,17 @@ export async function executeWorkflow(
|
|
|
666
726
|
request: { prompt, opts: leafOpts },
|
|
667
727
|
result: result.output,
|
|
668
728
|
status: "completed",
|
|
729
|
+
inputTokens: result.inputTokens,
|
|
730
|
+
outputTokens: result.outputTokens,
|
|
731
|
+
});
|
|
732
|
+
onLeaf?.({
|
|
733
|
+
type: "leaf_finished",
|
|
734
|
+
seq,
|
|
735
|
+
status: "completed",
|
|
736
|
+
...(leafOpts.label !== undefined ? { label: leafOpts.label } : {}),
|
|
737
|
+
inputTokens: result.inputTokens,
|
|
738
|
+
outputTokens: result.outputTokens,
|
|
739
|
+
resultSummary: summarizeForLeafEvent(result.output),
|
|
669
740
|
});
|
|
670
741
|
return { output: result.output, failed: false };
|
|
671
742
|
} catch (err) {
|
|
@@ -693,6 +764,17 @@ export async function executeWorkflow(
|
|
|
693
764
|
{ err, runId, seq, label: leafOpts.label },
|
|
694
765
|
"Workflow leaf failed",
|
|
695
766
|
);
|
|
767
|
+
onLeaf?.({
|
|
768
|
+
type: "leaf_finished",
|
|
769
|
+
seq,
|
|
770
|
+
status: "failed",
|
|
771
|
+
...(leafOpts.label !== undefined ? { label: leafOpts.label } : {}),
|
|
772
|
+
inputTokens: 0,
|
|
773
|
+
outputTokens: 0,
|
|
774
|
+
resultSummary: summarizeForLeafEvent(
|
|
775
|
+
err instanceof Error ? err.message : String(err),
|
|
776
|
+
),
|
|
777
|
+
});
|
|
696
778
|
return { output: null, failed: true };
|
|
697
779
|
}
|
|
698
780
|
};
|
|
@@ -197,6 +197,76 @@ describe("workflow journal store", () => {
|
|
|
197
197
|
});
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
+
test("appendJournalEntry round-trips per-leaf token usage", () => {
|
|
201
|
+
createRun({ id: "wf-tok", scriptSource: "s", scriptHash: "h" });
|
|
202
|
+
|
|
203
|
+
appendJournalEntry({
|
|
204
|
+
runId: "wf-tok",
|
|
205
|
+
seq: 0,
|
|
206
|
+
callHash: "c0",
|
|
207
|
+
kind: "agent",
|
|
208
|
+
request: { prompt: "with tokens" },
|
|
209
|
+
result: { text: "r" },
|
|
210
|
+
status: "completed",
|
|
211
|
+
inputTokens: 120,
|
|
212
|
+
outputTokens: 45,
|
|
213
|
+
});
|
|
214
|
+
// A leaf with no recorded usage stores NULL → reads back as undefined.
|
|
215
|
+
appendJournalEntry({
|
|
216
|
+
runId: "wf-tok",
|
|
217
|
+
seq: 1,
|
|
218
|
+
callHash: "c1",
|
|
219
|
+
kind: "agent",
|
|
220
|
+
request: { prompt: "no tokens" },
|
|
221
|
+
result: { error: "boom" },
|
|
222
|
+
status: "failed",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const journal = getJournal("wf-tok");
|
|
226
|
+
expect(journal[0]).toMatchObject({
|
|
227
|
+
seq: 0,
|
|
228
|
+
inputTokens: 120,
|
|
229
|
+
outputTokens: 45,
|
|
230
|
+
});
|
|
231
|
+
expect(journal[1].inputTokens).toBeUndefined();
|
|
232
|
+
expect(journal[1].outputTokens).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("appendJournalEntry upserts token usage on a changed-hash re-run", () => {
|
|
236
|
+
createRun({ id: "wf-tok-resume", scriptSource: "s", scriptHash: "h" });
|
|
237
|
+
|
|
238
|
+
appendJournalEntry({
|
|
239
|
+
runId: "wf-tok-resume",
|
|
240
|
+
seq: 0,
|
|
241
|
+
callHash: "hash-old",
|
|
242
|
+
kind: "agent",
|
|
243
|
+
result: { text: "old" },
|
|
244
|
+
status: "completed",
|
|
245
|
+
inputTokens: 10,
|
|
246
|
+
outputTokens: 5,
|
|
247
|
+
});
|
|
248
|
+
// Resume re-runs the leaf with new usage; the upsert overwrites the tokens.
|
|
249
|
+
appendJournalEntry({
|
|
250
|
+
runId: "wf-tok-resume",
|
|
251
|
+
seq: 0,
|
|
252
|
+
callHash: "hash-new",
|
|
253
|
+
kind: "agent",
|
|
254
|
+
result: { text: "new" },
|
|
255
|
+
status: "completed",
|
|
256
|
+
inputTokens: 30,
|
|
257
|
+
outputTokens: 12,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const journal = getJournal("wf-tok-resume");
|
|
261
|
+
expect(journal).toHaveLength(1);
|
|
262
|
+
expect(journal[0]).toMatchObject({
|
|
263
|
+
seq: 0,
|
|
264
|
+
callHash: "hash-new",
|
|
265
|
+
inputTokens: 30,
|
|
266
|
+
outputTokens: 12,
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
200
270
|
test("appendJournalEntry is idempotent on (run_id, seq)", () => {
|
|
201
271
|
createRun({ id: "wf-dup", scriptSource: "s", scriptHash: "h" });
|
|
202
272
|
|
|
@@ -68,6 +68,9 @@ export interface WorkflowJournalEntry {
|
|
|
68
68
|
request: unknown;
|
|
69
69
|
result: unknown;
|
|
70
70
|
status: string;
|
|
71
|
+
/** Per-leaf token usage for a completed agent leaf; undefined otherwise. */
|
|
72
|
+
inputTokens?: number;
|
|
73
|
+
outputTokens?: number;
|
|
71
74
|
createdAt: number | null;
|
|
72
75
|
}
|
|
73
76
|
|
|
@@ -111,6 +114,9 @@ export interface AppendJournalEntryInput {
|
|
|
111
114
|
request?: unknown;
|
|
112
115
|
result?: unknown;
|
|
113
116
|
status: string;
|
|
117
|
+
/** Per-leaf token usage; set for a completed agent leaf, omitted otherwise. */
|
|
118
|
+
inputTokens?: number;
|
|
119
|
+
outputTokens?: number;
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
// ---------------------------------------------------------------------------
|
|
@@ -145,6 +151,8 @@ interface WorkflowJournalRow {
|
|
|
145
151
|
request_json: string | null;
|
|
146
152
|
result_json: string | null;
|
|
147
153
|
status: string;
|
|
154
|
+
input_tokens: number | null;
|
|
155
|
+
output_tokens: number | null;
|
|
148
156
|
created_at: number | null;
|
|
149
157
|
}
|
|
150
158
|
|
|
@@ -194,6 +202,8 @@ function rowToJournalEntry(row: WorkflowJournalRow): WorkflowJournalEntry {
|
|
|
194
202
|
request: parseJsonColumn(row.request_json),
|
|
195
203
|
result: parseJsonColumn(row.result_json),
|
|
196
204
|
status: row.status,
|
|
205
|
+
inputTokens: row.input_tokens ?? undefined,
|
|
206
|
+
outputTokens: row.output_tokens ?? undefined,
|
|
197
207
|
createdAt: row.created_at,
|
|
198
208
|
};
|
|
199
209
|
}
|
|
@@ -318,14 +328,17 @@ export function appendJournalEntry(
|
|
|
318
328
|
rawRun(
|
|
319
329
|
/*sql*/ `
|
|
320
330
|
INSERT INTO workflow_journal (
|
|
321
|
-
run_id, seq, call_hash, kind, request_json, result_json, status,
|
|
322
|
-
|
|
331
|
+
run_id, seq, call_hash, kind, request_json, result_json, status,
|
|
332
|
+
input_tokens, output_tokens, created_at
|
|
333
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
323
334
|
ON CONFLICT(run_id, seq) DO UPDATE SET
|
|
324
335
|
call_hash = excluded.call_hash,
|
|
325
336
|
kind = excluded.kind,
|
|
326
337
|
request_json = excluded.request_json,
|
|
327
338
|
result_json = excluded.result_json,
|
|
328
|
-
status = excluded.status
|
|
339
|
+
status = excluded.status,
|
|
340
|
+
input_tokens = excluded.input_tokens,
|
|
341
|
+
output_tokens = excluded.output_tokens
|
|
329
342
|
`,
|
|
330
343
|
input.runId,
|
|
331
344
|
input.seq,
|
|
@@ -334,6 +347,8 @@ export function appendJournalEntry(
|
|
|
334
347
|
serializeJsonColumn(input.request),
|
|
335
348
|
serializeJsonColumn(input.result),
|
|
336
349
|
input.status,
|
|
350
|
+
input.inputTokens ?? null,
|
|
351
|
+
input.outputTokens ?? null,
|
|
337
352
|
Date.now(),
|
|
338
353
|
);
|
|
339
354
|
return getJournalEntry(input.runId, input.seq)!;
|
|
@@ -292,6 +292,7 @@ describe("WorkflowRunManager.abort", () => {
|
|
|
292
292
|
scriptSource: "export const meta = { name: 'x', description: 'y' }",
|
|
293
293
|
args: {},
|
|
294
294
|
manifest: { tools: [], hostFunctions: [], persona: false },
|
|
295
|
+
conversationId: "conv-1",
|
|
295
296
|
trustContext: TRUST,
|
|
296
297
|
});
|
|
297
298
|
|
|
@@ -318,6 +319,7 @@ describe("WorkflowRunManager — progress events", () => {
|
|
|
318
319
|
scriptSource: "export const meta = { name: 'x', description: 'y' }",
|
|
319
320
|
args: {},
|
|
320
321
|
manifest: { tools: [], hostFunctions: [], persona: false },
|
|
322
|
+
conversationId: "conv-1",
|
|
321
323
|
label: "My Flow",
|
|
322
324
|
trustContext: TRUST,
|
|
323
325
|
});
|
|
@@ -330,14 +332,164 @@ describe("WorkflowRunManager — progress events", () => {
|
|
|
330
332
|
expect(progress).toHaveLength(2);
|
|
331
333
|
expect(progress[0]).toMatchObject({
|
|
332
334
|
type: "workflow_progress",
|
|
335
|
+
conversationId: "conv-1",
|
|
333
336
|
label: "My Flow",
|
|
334
337
|
phase: "Gathering",
|
|
335
338
|
});
|
|
336
339
|
expect(progress[1]).toMatchObject({
|
|
337
340
|
type: "workflow_progress",
|
|
341
|
+
conversationId: "conv-1",
|
|
338
342
|
message: "step done",
|
|
339
343
|
});
|
|
340
344
|
});
|
|
345
|
+
|
|
346
|
+
test("a run with no conversation broadcasts progress unscoped (no conversationId)", () => {
|
|
347
|
+
const h = makeHarness();
|
|
348
|
+
h.manager.start({
|
|
349
|
+
scriptSource: "export const meta = { name: 'x', description: 'y' }",
|
|
350
|
+
args: {},
|
|
351
|
+
manifest: { tools: [], hostFunctions: [], persona: false },
|
|
352
|
+
label: "My Flow",
|
|
353
|
+
trustContext: TRUST,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const onProgress = h.executeCalls[0]!.onProgress!;
|
|
357
|
+
onProgress({ type: "phase", title: "Gathering" });
|
|
358
|
+
onProgress({ type: "log", message: "step done" });
|
|
359
|
+
|
|
360
|
+
// `workflow_progress` is a pre-existing event; it still broadcasts for a
|
|
361
|
+
// conversationless run, just without a `conversationId` (unscoped).
|
|
362
|
+
const progress = h.broadcasts.filter((b) => b.type === "workflow_progress");
|
|
363
|
+
expect(progress).toHaveLength(2);
|
|
364
|
+
expect(progress[0]).toMatchObject({
|
|
365
|
+
type: "workflow_progress",
|
|
366
|
+
label: "My Flow",
|
|
367
|
+
phase: "Gathering",
|
|
368
|
+
});
|
|
369
|
+
expect(progress[0]).not.toHaveProperty("conversationId");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("onLeaf start/finish are republished as scoped leaf events", () => {
|
|
373
|
+
const fakeEngine: WorkflowRunManagerDeps["executeWorkflow"] = (options) => {
|
|
374
|
+
options.onLeaf?.({
|
|
375
|
+
type: "leaf_started",
|
|
376
|
+
seq: 1,
|
|
377
|
+
label: "Research",
|
|
378
|
+
phase: "Gather",
|
|
379
|
+
promptSummary: "look it up",
|
|
380
|
+
});
|
|
381
|
+
options.onLeaf?.({
|
|
382
|
+
type: "leaf_finished",
|
|
383
|
+
seq: 1,
|
|
384
|
+
status: "completed",
|
|
385
|
+
label: "Research",
|
|
386
|
+
inputTokens: 30,
|
|
387
|
+
outputTokens: 12,
|
|
388
|
+
resultSummary: "found it",
|
|
389
|
+
});
|
|
390
|
+
return new Promise(() => {}) as ReturnType<
|
|
391
|
+
WorkflowRunManagerDeps["executeWorkflow"]
|
|
392
|
+
>;
|
|
393
|
+
};
|
|
394
|
+
const h = makeHarness({ engine: fakeEngine });
|
|
395
|
+
|
|
396
|
+
const { runId } = h.manager.start({
|
|
397
|
+
scriptSource: "export const meta = { name: 'x', description: 'y' }",
|
|
398
|
+
args: {},
|
|
399
|
+
manifest: { tools: [], hostFunctions: [], persona: false },
|
|
400
|
+
conversationId: "conv-1",
|
|
401
|
+
trustContext: TRUST,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const started = h.broadcasts.find(
|
|
405
|
+
(b) => b.type === "workflow_leaf_started",
|
|
406
|
+
);
|
|
407
|
+
expect(started).toMatchObject({
|
|
408
|
+
type: "workflow_leaf_started",
|
|
409
|
+
runId,
|
|
410
|
+
conversationId: "conv-1",
|
|
411
|
+
seq: 1,
|
|
412
|
+
label: "Research",
|
|
413
|
+
phase: "Gather",
|
|
414
|
+
promptSummary: "look it up",
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
const finished = h.broadcasts.find(
|
|
418
|
+
(b) => b.type === "workflow_leaf_finished",
|
|
419
|
+
);
|
|
420
|
+
expect(finished).toMatchObject({
|
|
421
|
+
type: "workflow_leaf_finished",
|
|
422
|
+
runId,
|
|
423
|
+
conversationId: "conv-1",
|
|
424
|
+
seq: 1,
|
|
425
|
+
status: "completed",
|
|
426
|
+
label: "Research",
|
|
427
|
+
inputTokens: 30,
|
|
428
|
+
outputTokens: 12,
|
|
429
|
+
resultSummary: "found it",
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("a run with no conversation gets no onLeaf callback", () => {
|
|
434
|
+
const h = makeHarness();
|
|
435
|
+
h.manager.start({
|
|
436
|
+
scriptSource: "export const meta = { name: 'x', description: 'y' }",
|
|
437
|
+
args: {},
|
|
438
|
+
manifest: { tools: [], hostFunctions: [], persona: false },
|
|
439
|
+
trustContext: TRUST,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
expect(h.executeCalls[0]!.onLeaf).toBeUndefined();
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe("WorkflowRunManager.start — workflow_started", () => {
|
|
447
|
+
test("start with a conversation broadcasts workflow_started carrying toolUseId", () => {
|
|
448
|
+
const h = makeHarness();
|
|
449
|
+
const { runId } = h.manager.start({
|
|
450
|
+
scriptSource: "export const meta = { name: 'x', description: 'y' }",
|
|
451
|
+
args: {},
|
|
452
|
+
manifest: { tools: [], hostFunctions: [], persona: false },
|
|
453
|
+
conversationId: "conv-1",
|
|
454
|
+
toolUseId: "toolu-abc",
|
|
455
|
+
label: "My Flow",
|
|
456
|
+
trustContext: TRUST,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const started = h.broadcasts.find((b) => b.type === "workflow_started");
|
|
460
|
+
expect(started).toMatchObject({
|
|
461
|
+
type: "workflow_started",
|
|
462
|
+
runId,
|
|
463
|
+
conversationId: "conv-1",
|
|
464
|
+
toolUseId: "toolu-abc",
|
|
465
|
+
label: "My Flow",
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("start without a conversation broadcasts no workflow_started", () => {
|
|
470
|
+
const h = makeHarness();
|
|
471
|
+
h.manager.start({
|
|
472
|
+
scriptSource: "export const meta = { name: 'x', description: 'y' }",
|
|
473
|
+
args: {},
|
|
474
|
+
manifest: { tools: [], hostFunctions: [], persona: false },
|
|
475
|
+
trustContext: TRUST,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
expect(h.broadcasts.some((b) => b.type === "workflow_started")).toBe(false);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("resume never broadcasts workflow_started", () => {
|
|
482
|
+
const h = makeHarness({});
|
|
483
|
+
seedRun(h, {
|
|
484
|
+
id: "run-x",
|
|
485
|
+
status: "interrupted",
|
|
486
|
+
conversationId: "conv-1",
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
h.manager.resume("run-x");
|
|
490
|
+
|
|
491
|
+
expect(h.broadcasts.some((b) => b.type === "workflow_started")).toBe(false);
|
|
492
|
+
});
|
|
341
493
|
});
|
|
342
494
|
|
|
343
495
|
describe("WorkflowRunManager — completion", () => {
|
|
@@ -364,6 +516,7 @@ describe("WorkflowRunManager — completion", () => {
|
|
|
364
516
|
expect(completed).toMatchObject({
|
|
365
517
|
type: "workflow_completed",
|
|
366
518
|
runId,
|
|
519
|
+
conversationId: "conv-1",
|
|
367
520
|
status: "completed",
|
|
368
521
|
agentsSpawned: 4,
|
|
369
522
|
inputTokens: 100,
|
|
@@ -418,7 +571,7 @@ describe("WorkflowRunManager — completion", () => {
|
|
|
418
571
|
expect(h.manager.status(runId)?.result).toBe(big);
|
|
419
572
|
});
|
|
420
573
|
|
|
421
|
-
test("no conversationId →
|
|
574
|
+
test("no conversationId → pre-existing events broadcast unscoped; UI events and wake are gated", async () => {
|
|
422
575
|
const h = makeHarness();
|
|
423
576
|
h.manager.start({
|
|
424
577
|
scriptSource: "export const meta = { name: 'x', description: 'y' }",
|
|
@@ -435,8 +588,23 @@ describe("WorkflowRunManager — completion", () => {
|
|
|
435
588
|
outputTokens: 1,
|
|
436
589
|
});
|
|
437
590
|
|
|
438
|
-
|
|
439
|
-
|
|
591
|
+
// The pre-existing terminal event still broadcasts (unscoped) so raw SSE
|
|
592
|
+
// listeners and conversationless scheduled runs are still surfaced.
|
|
593
|
+
const completed = h.broadcasts.find((b) => b.type === "workflow_completed");
|
|
594
|
+
expect(completed).toMatchObject({
|
|
595
|
+
type: "workflow_completed",
|
|
596
|
+
status: "completed",
|
|
597
|
+
});
|
|
598
|
+
expect(completed).not.toHaveProperty("conversationId");
|
|
599
|
+
|
|
600
|
+
// The conversation-only signals stay gated: no `workflow_started`, no leaf
|
|
601
|
+
// events, and no completion wake without an originating conversation.
|
|
602
|
+
expect(h.broadcasts.some((b) => b.type === "workflow_started")).toBe(false);
|
|
603
|
+
expect(h.broadcasts.some((b) => b.type === "workflow_leaf_started")).toBe(
|
|
604
|
+
false,
|
|
605
|
+
);
|
|
606
|
+
expect(h.broadcasts.some((b) => b.type === "workflow_leaf_finished")).toBe(
|
|
607
|
+
false,
|
|
440
608
|
);
|
|
441
609
|
expect(h.wakes).toHaveLength(0);
|
|
442
610
|
});
|
|
@@ -114,6 +114,11 @@ interface StartWorkflowCommon {
|
|
|
114
114
|
* scheduled run with no chat surface) — the summary is then events-only.
|
|
115
115
|
*/
|
|
116
116
|
conversationId?: string;
|
|
117
|
+
/**
|
|
118
|
+
* The `skill_execute` tool-use block id that launched this run; forwarded on
|
|
119
|
+
* `workflow_started` so the client anchors the inline card to the spawn call.
|
|
120
|
+
*/
|
|
121
|
+
toolUseId?: string;
|
|
117
122
|
/** Human-readable label for display; defaults to the run id. */
|
|
118
123
|
label?: string;
|
|
119
124
|
/** Trust/auth context forwarded to every leaf. */
|
|
@@ -215,6 +220,19 @@ export class WorkflowRunManager {
|
|
|
215
220
|
const controller = new AbortController();
|
|
216
221
|
this.inflight.set(runId, controller);
|
|
217
222
|
|
|
223
|
+
// Announce the launch only when there's a conversation to scope it to (the
|
|
224
|
+
// same gate the in-flight/terminal broadcasts use). `resume` never emits
|
|
225
|
+
// this — the run already announced itself on its original `start`.
|
|
226
|
+
if (opts.conversationId) {
|
|
227
|
+
this.deps.broadcast({
|
|
228
|
+
type: "workflow_started",
|
|
229
|
+
runId,
|
|
230
|
+
conversationId: opts.conversationId,
|
|
231
|
+
...(opts.toolUseId ? { toolUseId: opts.toolUseId } : {}),
|
|
232
|
+
label,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
218
236
|
// Fire-and-forget: the engine owns its own try/catch and always finishes
|
|
219
237
|
// the run row. We never await it — `start` must return synchronously.
|
|
220
238
|
void this.runToCompletion(
|
|
@@ -376,6 +394,14 @@ export class WorkflowRunManager {
|
|
|
376
394
|
ctx: RunContext,
|
|
377
395
|
): Promise<void> {
|
|
378
396
|
const config = this.deps.getConfig();
|
|
397
|
+
// `workflow_progress` / `workflow_completed` always broadcast: a run with no
|
|
398
|
+
// originating conversation emits them unscoped (no `conversationId`) for raw
|
|
399
|
+
// SSE listeners and the DB record, preserving the pre-scoping contract that
|
|
400
|
+
// surfaces conversationless scheduled runs. The conversation-only signals —
|
|
401
|
+
// `workflow_started`, the `onLeaf` leaf events, and the completion wake —
|
|
402
|
+
// stay gated on `conversationId` since they exist solely to drive the inline
|
|
403
|
+
// workflow card in that conversation.
|
|
404
|
+
const conversationId = ctx.conversationId;
|
|
379
405
|
try {
|
|
380
406
|
const result = await this.deps.executeWorkflow({
|
|
381
407
|
runId,
|
|
@@ -393,6 +419,7 @@ export class WorkflowRunManager {
|
|
|
393
419
|
this.deps.broadcast({
|
|
394
420
|
type: "workflow_progress",
|
|
395
421
|
runId,
|
|
422
|
+
...(conversationId ? { conversationId } : {}),
|
|
396
423
|
label,
|
|
397
424
|
agentsSpawned,
|
|
398
425
|
...(event.type === "phase"
|
|
@@ -400,6 +427,41 @@ export class WorkflowRunManager {
|
|
|
400
427
|
: { message: event.message }),
|
|
401
428
|
});
|
|
402
429
|
},
|
|
430
|
+
...(conversationId
|
|
431
|
+
? {
|
|
432
|
+
onLeaf: (event) => {
|
|
433
|
+
if (event.type === "leaf_started") {
|
|
434
|
+
this.deps.broadcast({
|
|
435
|
+
type: "workflow_leaf_started",
|
|
436
|
+
runId,
|
|
437
|
+
conversationId,
|
|
438
|
+
seq: event.seq,
|
|
439
|
+
...(event.label !== undefined
|
|
440
|
+
? { label: event.label }
|
|
441
|
+
: {}),
|
|
442
|
+
...(event.phase !== undefined
|
|
443
|
+
? { phase: event.phase }
|
|
444
|
+
: {}),
|
|
445
|
+
promptSummary: event.promptSummary,
|
|
446
|
+
});
|
|
447
|
+
} else {
|
|
448
|
+
this.deps.broadcast({
|
|
449
|
+
type: "workflow_leaf_finished",
|
|
450
|
+
runId,
|
|
451
|
+
conversationId,
|
|
452
|
+
seq: event.seq,
|
|
453
|
+
status: event.status,
|
|
454
|
+
...(event.label !== undefined
|
|
455
|
+
? { label: event.label }
|
|
456
|
+
: {}),
|
|
457
|
+
inputTokens: event.inputTokens,
|
|
458
|
+
outputTokens: event.outputTokens,
|
|
459
|
+
resultSummary: event.resultSummary,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
}
|
|
464
|
+
: {}),
|
|
403
465
|
});
|
|
404
466
|
|
|
405
467
|
const summary = buildCompletionSummary(
|
|
@@ -412,6 +474,7 @@ export class WorkflowRunManager {
|
|
|
412
474
|
this.deps.broadcast({
|
|
413
475
|
type: "workflow_completed",
|
|
414
476
|
runId,
|
|
477
|
+
...(conversationId ? { conversationId } : {}),
|
|
415
478
|
status: result.status,
|
|
416
479
|
agentsSpawned: result.agentsSpawned,
|
|
417
480
|
inputTokens: result.inputTokens,
|