@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
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
WorkflowJournalEntry,
|
|
5
|
+
WorkflowRun,
|
|
6
|
+
} from "../../workflows/journal-store.js";
|
|
4
7
|
import type { SavedWorkflowEntry } from "../../workflows/library.js";
|
|
5
8
|
import {
|
|
6
9
|
WorkflowResumeNotPossibleError,
|
|
@@ -58,6 +61,22 @@ interface FakeManager {
|
|
|
58
61
|
resume: (id: string) => { runId: string };
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
function makeJournalEntry(
|
|
65
|
+
overrides: Partial<WorkflowJournalEntry> = {},
|
|
66
|
+
): WorkflowJournalEntry {
|
|
67
|
+
return {
|
|
68
|
+
runId: "run-1",
|
|
69
|
+
seq: 0,
|
|
70
|
+
callHash: "call-0",
|
|
71
|
+
kind: "agent",
|
|
72
|
+
request: null,
|
|
73
|
+
result: null,
|
|
74
|
+
status: "completed",
|
|
75
|
+
createdAt: 1000,
|
|
76
|
+
...overrides,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
61
80
|
function setup(opts: {
|
|
62
81
|
runs?: WorkflowRun[];
|
|
63
82
|
saved?: SavedWorkflowEntry[];
|
|
@@ -65,6 +84,8 @@ function setup(opts: {
|
|
|
65
84
|
resume?: (id: string) => { runId: string };
|
|
66
85
|
/** Resolved auto-approve threshold for the resume posture gate. */
|
|
67
86
|
threshold?: "none" | "low" | "medium" | "high";
|
|
87
|
+
/** Journal entries returned by getJournal, keyed by run id. */
|
|
88
|
+
journal?: Record<string, WorkflowJournalEntry[]>;
|
|
68
89
|
}): {
|
|
69
90
|
aborted: string[];
|
|
70
91
|
resumed: string[];
|
|
@@ -97,6 +118,7 @@ function setup(opts: {
|
|
|
97
118
|
getManager: () => manager,
|
|
98
119
|
listWorkflows: () => opts.saved ?? [],
|
|
99
120
|
getAutoApproveThreshold: async () => opts.threshold ?? "none",
|
|
121
|
+
getJournal: (runId) => opts.journal?.[runId] ?? [],
|
|
100
122
|
});
|
|
101
123
|
return { aborted, resumed, listCalls };
|
|
102
124
|
}
|
|
@@ -303,6 +325,208 @@ describe("workflow routes (happy paths)", () => {
|
|
|
303
325
|
});
|
|
304
326
|
});
|
|
305
327
|
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Journal projection
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
interface WireLeaf {
|
|
333
|
+
seq: number;
|
|
334
|
+
kind: string;
|
|
335
|
+
label?: string;
|
|
336
|
+
phase?: string;
|
|
337
|
+
promptSummary?: string;
|
|
338
|
+
status: string;
|
|
339
|
+
resultSummary?: string;
|
|
340
|
+
inputTokens?: number;
|
|
341
|
+
outputTokens?: number;
|
|
342
|
+
createdAt: number | null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
interface WireJournal {
|
|
346
|
+
runId: string;
|
|
347
|
+
status: string;
|
|
348
|
+
agentsSpawned: number;
|
|
349
|
+
inputTokens: number;
|
|
350
|
+
outputTokens: number;
|
|
351
|
+
leaves: WireLeaf[];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
describe("getWorkflowRunJournal", () => {
|
|
355
|
+
test("projects agent leaves with label/phase/promptSummary/resultSummary extracted", async () => {
|
|
356
|
+
setup({
|
|
357
|
+
runs: [makeRun({ id: "run-1" })],
|
|
358
|
+
journal: {
|
|
359
|
+
"run-1": [
|
|
360
|
+
makeJournalEntry({
|
|
361
|
+
seq: 0,
|
|
362
|
+
kind: "agent",
|
|
363
|
+
request: {
|
|
364
|
+
prompt: "Investigate the failing build",
|
|
365
|
+
opts: { label: "investigate", phase: "triage" },
|
|
366
|
+
},
|
|
367
|
+
result: { summary: "found the flaky test" },
|
|
368
|
+
status: "completed",
|
|
369
|
+
inputTokens: 120,
|
|
370
|
+
outputTokens: 45,
|
|
371
|
+
}),
|
|
372
|
+
],
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
const result = (await route("getWorkflowRunJournal").handler({
|
|
376
|
+
pathParams: { id: "run-1" },
|
|
377
|
+
})) as WireJournal;
|
|
378
|
+
|
|
379
|
+
expect(result.runId).toBe("run-1");
|
|
380
|
+
expect(result.status).toBe("running");
|
|
381
|
+
expect(result.agentsSpawned).toBe(2);
|
|
382
|
+
expect(result.leaves.map((l) => l.seq)).toEqual([0]);
|
|
383
|
+
|
|
384
|
+
const first = result.leaves[0];
|
|
385
|
+
expect(first.kind).toBe("agent");
|
|
386
|
+
expect(first.label).toBe("investigate");
|
|
387
|
+
expect(first.phase).toBe("triage");
|
|
388
|
+
expect(first.promptSummary).toBe("Investigate the failing build");
|
|
389
|
+
expect(first.resultSummary).toContain("found the flaky test");
|
|
390
|
+
// Per-leaf token usage is projected onto the wire leaf.
|
|
391
|
+
expect(first.inputTokens).toBe(120);
|
|
392
|
+
expect(first.outputTokens).toBe(45);
|
|
393
|
+
// The bulky raw request/result payloads are dropped.
|
|
394
|
+
expect(first).not.toHaveProperty("request");
|
|
395
|
+
expect(first).not.toHaveProperty("result");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("omits per-leaf token fields when the journal entry carries none", async () => {
|
|
399
|
+
setup({
|
|
400
|
+
runs: [makeRun({ id: "run-1" })],
|
|
401
|
+
journal: {
|
|
402
|
+
"run-1": [
|
|
403
|
+
makeJournalEntry({
|
|
404
|
+
seq: 0,
|
|
405
|
+
kind: "agent",
|
|
406
|
+
request: { prompt: "no tokens recorded", opts: {} },
|
|
407
|
+
status: "completed",
|
|
408
|
+
}),
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
const result = (await route("getWorkflowRunJournal").handler({
|
|
413
|
+
pathParams: { id: "run-1" },
|
|
414
|
+
})) as WireJournal;
|
|
415
|
+
|
|
416
|
+
const leaf = result.leaves[0];
|
|
417
|
+
expect(leaf).not.toHaveProperty("inputTokens");
|
|
418
|
+
expect(leaf).not.toHaveProperty("outputTokens");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("excludes kind:workflow entries so the backfill matches the live agent-only stream", async () => {
|
|
422
|
+
// The live `workflow_leaf_*` stream is emitted only for agent leaves; nested
|
|
423
|
+
// `workflow(name)` resolutions never reach it. The journal route must filter
|
|
424
|
+
// to agent leaves too, or a nested-workflow run renders a phantom unlabeled
|
|
425
|
+
// node on backfill and a different leaf set than live.
|
|
426
|
+
setup({
|
|
427
|
+
runs: [makeRun({ id: "run-1" })],
|
|
428
|
+
journal: {
|
|
429
|
+
"run-1": [
|
|
430
|
+
makeJournalEntry({
|
|
431
|
+
seq: 0,
|
|
432
|
+
kind: "agent",
|
|
433
|
+
request: { prompt: "first agent", opts: { label: "first" } },
|
|
434
|
+
status: "completed",
|
|
435
|
+
}),
|
|
436
|
+
makeJournalEntry({
|
|
437
|
+
seq: 1,
|
|
438
|
+
kind: "workflow",
|
|
439
|
+
request: { name: "child", args: { foo: 1 } },
|
|
440
|
+
result: "child done",
|
|
441
|
+
status: "completed",
|
|
442
|
+
}),
|
|
443
|
+
makeJournalEntry({
|
|
444
|
+
seq: 2,
|
|
445
|
+
kind: "agent",
|
|
446
|
+
request: { prompt: "second agent", opts: { label: "second" } },
|
|
447
|
+
status: "completed",
|
|
448
|
+
}),
|
|
449
|
+
],
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
const result = (await route("getWorkflowRunJournal").handler({
|
|
453
|
+
pathParams: { id: "run-1" },
|
|
454
|
+
})) as WireJournal;
|
|
455
|
+
|
|
456
|
+
// Only the agent leaves survive, in seq order; the workflow-kind entry (seq
|
|
457
|
+
// 1) is dropped entirely.
|
|
458
|
+
expect(result.leaves.map((l) => l.seq)).toEqual([0, 2]);
|
|
459
|
+
expect(result.leaves.every((l) => l.kind === "agent")).toBe(true);
|
|
460
|
+
expect(result.leaves.map((l) => l.label)).toEqual(["first", "second"]);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("surfaces a failed leaf's { error } result as resultSummary", async () => {
|
|
464
|
+
setup({
|
|
465
|
+
runs: [makeRun({ id: "run-1" })],
|
|
466
|
+
journal: {
|
|
467
|
+
"run-1": [
|
|
468
|
+
makeJournalEntry({
|
|
469
|
+
seq: 0,
|
|
470
|
+
status: "failed",
|
|
471
|
+
request: { prompt: "do the thing", opts: {} },
|
|
472
|
+
result: { error: "agent crashed: out of tokens" },
|
|
473
|
+
}),
|
|
474
|
+
],
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
const result = (await route("getWorkflowRunJournal").handler({
|
|
478
|
+
pathParams: { id: "run-1" },
|
|
479
|
+
})) as WireJournal;
|
|
480
|
+
|
|
481
|
+
expect(result.leaves[0].status).toBe("failed");
|
|
482
|
+
expect(result.leaves[0].resultSummary).toContain(
|
|
483
|
+
"agent crashed: out of tokens",
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("truncates a long prompt/result to ~200 chars", async () => {
|
|
488
|
+
const longPrompt = "x".repeat(500);
|
|
489
|
+
const longResult = "y".repeat(500);
|
|
490
|
+
setup({
|
|
491
|
+
runs: [makeRun({ id: "run-1" })],
|
|
492
|
+
journal: {
|
|
493
|
+
"run-1": [
|
|
494
|
+
makeJournalEntry({
|
|
495
|
+
seq: 0,
|
|
496
|
+
request: { prompt: longPrompt, opts: {} },
|
|
497
|
+
result: longResult,
|
|
498
|
+
}),
|
|
499
|
+
],
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
const result = (await route("getWorkflowRunJournal").handler({
|
|
503
|
+
pathParams: { id: "run-1" },
|
|
504
|
+
})) as WireJournal;
|
|
505
|
+
|
|
506
|
+
const leaf = result.leaves[0];
|
|
507
|
+
expect(leaf.promptSummary!.length).toBeLessThanOrEqual(201);
|
|
508
|
+
expect(leaf.promptSummary!.startsWith("x")).toBe(true);
|
|
509
|
+
// A truncated summary ends with the ellipsis marker.
|
|
510
|
+
expect(leaf.promptSummary!.endsWith("…")).toBe(true);
|
|
511
|
+
expect(leaf.resultSummary!.length).toBeLessThanOrEqual(201);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("returns an empty leaf list for a run with no journal entries", async () => {
|
|
515
|
+
setup({ runs: [makeRun({ id: "run-1" })] });
|
|
516
|
+
const result = (await route("getWorkflowRunJournal").handler({
|
|
517
|
+
pathParams: { id: "run-1" },
|
|
518
|
+
})) as WireJournal;
|
|
519
|
+
expect(result.leaves).toEqual([]);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("throws NotFoundError for an unknown id", () => {
|
|
523
|
+
setup({ runs: [] });
|
|
524
|
+
expect(() =>
|
|
525
|
+
route("getWorkflowRunJournal").handler({ pathParams: { id: "nope" } }),
|
|
526
|
+
).toThrow(NotFoundError);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
306
530
|
// ---------------------------------------------------------------------------
|
|
307
531
|
// Unknown run → 404
|
|
308
532
|
// ---------------------------------------------------------------------------
|
|
@@ -12,7 +12,11 @@ import { z } from "zod";
|
|
|
12
12
|
import { getAutoApproveThreshold } from "../../permissions/gateway-threshold-reader.js";
|
|
13
13
|
import { isFullAccessThreshold } from "../../permissions/threshold.js";
|
|
14
14
|
import { manifestGrantsSideEffects } from "../../workflows/capabilities.js";
|
|
15
|
-
import
|
|
15
|
+
import {
|
|
16
|
+
getJournal,
|
|
17
|
+
type WorkflowJournalEntry,
|
|
18
|
+
type WorkflowRun,
|
|
19
|
+
} from "../../workflows/journal-store.js";
|
|
16
20
|
import { listWorkflows } from "../../workflows/library.js";
|
|
17
21
|
import {
|
|
18
22
|
getWorkflowRunManager,
|
|
@@ -40,6 +44,7 @@ export interface WorkflowRoutesDeps {
|
|
|
40
44
|
>;
|
|
41
45
|
listWorkflows: typeof listWorkflows;
|
|
42
46
|
getAutoApproveThreshold: typeof getAutoApproveThreshold;
|
|
47
|
+
getJournal: typeof getJournal;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
function defaultDeps(): WorkflowRoutesDeps {
|
|
@@ -47,6 +52,7 @@ function defaultDeps(): WorkflowRoutesDeps {
|
|
|
47
52
|
getManager: getWorkflowRunManager,
|
|
48
53
|
listWorkflows,
|
|
49
54
|
getAutoApproveThreshold,
|
|
55
|
+
getJournal,
|
|
50
56
|
};
|
|
51
57
|
}
|
|
52
58
|
|
|
@@ -93,6 +99,29 @@ const savedWorkflowSchema = z.object({
|
|
|
93
99
|
path: z.string(),
|
|
94
100
|
});
|
|
95
101
|
|
|
102
|
+
const workflowLeafSchema = z.object({
|
|
103
|
+
seq: z.number(),
|
|
104
|
+
kind: z.enum(["agent", "workflow"]),
|
|
105
|
+
label: z.string().optional(),
|
|
106
|
+
phase: z.string().optional(),
|
|
107
|
+
promptSummary: z.string().optional(),
|
|
108
|
+
status: z.string(),
|
|
109
|
+
resultSummary: z.string().optional(),
|
|
110
|
+
inputTokens: z.number().optional(),
|
|
111
|
+
outputTokens: z.number().optional(),
|
|
112
|
+
createdAt: z.number().nullable(),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const workflowJournalSchema = z.object({
|
|
116
|
+
runId: z.string(),
|
|
117
|
+
status: z.enum(VALID_RUN_STATUSES).optional(),
|
|
118
|
+
agentsSpawned: z.number().optional(),
|
|
119
|
+
inputTokens: z.number().optional(),
|
|
120
|
+
outputTokens: z.number().optional(),
|
|
121
|
+
phase: z.string().optional(),
|
|
122
|
+
leaves: z.array(workflowLeafSchema),
|
|
123
|
+
});
|
|
124
|
+
|
|
96
125
|
// ---------------------------------------------------------------------------
|
|
97
126
|
// Handlers (transport-agnostic)
|
|
98
127
|
// ---------------------------------------------------------------------------
|
|
@@ -122,6 +151,64 @@ export function toWireRun(run: WorkflowRun): WorkflowRunWire {
|
|
|
122
151
|
};
|
|
123
152
|
}
|
|
124
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Render an arbitrary journal value (prompt, result, `{ error }`) into a short
|
|
156
|
+
* single-string preview, truncating to `max` characters. Objects are
|
|
157
|
+
* JSON-stringified so a structured result still produces a readable summary.
|
|
158
|
+
*/
|
|
159
|
+
function summarize(value: unknown, max = 200): string | undefined {
|
|
160
|
+
if (value === undefined || value === null) return undefined;
|
|
161
|
+
let text: string;
|
|
162
|
+
if (typeof value === "string") {
|
|
163
|
+
text = value;
|
|
164
|
+
} else {
|
|
165
|
+
try {
|
|
166
|
+
text = JSON.stringify(value);
|
|
167
|
+
} catch {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Project a journal entry into the bounded leaf wire shape, dropping the bulky
|
|
176
|
+
* raw `request`/`result` payloads in favor of short summaries. The `request`
|
|
177
|
+
* and `result` columns are `unknown`, so read their fields defensively.
|
|
178
|
+
*/
|
|
179
|
+
export function toWireLeaf(
|
|
180
|
+
entry: WorkflowJournalEntry,
|
|
181
|
+
): z.infer<typeof workflowLeafSchema> {
|
|
182
|
+
const request =
|
|
183
|
+
entry.request && typeof entry.request === "object"
|
|
184
|
+
? (entry.request as Record<string, unknown>)
|
|
185
|
+
: undefined;
|
|
186
|
+
const opts =
|
|
187
|
+
request?.opts && typeof request.opts === "object"
|
|
188
|
+
? (request.opts as Record<string, unknown>)
|
|
189
|
+
: undefined;
|
|
190
|
+
const label = typeof opts?.label === "string" ? opts.label : undefined;
|
|
191
|
+
const phase = typeof opts?.phase === "string" ? opts.phase : undefined;
|
|
192
|
+
const promptSummary = summarize(request?.prompt);
|
|
193
|
+
const resultSummary = summarize(entry.result);
|
|
194
|
+
return {
|
|
195
|
+
seq: entry.seq,
|
|
196
|
+
kind: entry.kind,
|
|
197
|
+
...(label !== undefined ? { label } : {}),
|
|
198
|
+
...(phase !== undefined ? { phase } : {}),
|
|
199
|
+
...(promptSummary !== undefined ? { promptSummary } : {}),
|
|
200
|
+
status: entry.status,
|
|
201
|
+
...(resultSummary !== undefined ? { resultSummary } : {}),
|
|
202
|
+
...(entry.inputTokens !== undefined
|
|
203
|
+
? { inputTokens: entry.inputTokens }
|
|
204
|
+
: {}),
|
|
205
|
+
...(entry.outputTokens !== undefined
|
|
206
|
+
? { outputTokens: entry.outputTokens }
|
|
207
|
+
: {}),
|
|
208
|
+
createdAt: entry.createdAt,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
125
212
|
function parseLimit(raw: string | undefined): number | undefined {
|
|
126
213
|
if (raw === undefined) return undefined;
|
|
127
214
|
const n = Number(raw);
|
|
@@ -156,6 +243,28 @@ function handleGetRun(id: string) {
|
|
|
156
243
|
return toWireRun(run);
|
|
157
244
|
}
|
|
158
245
|
|
|
246
|
+
function handleGetRunJournal(id: string) {
|
|
247
|
+
const run = deps.getManager().status(id);
|
|
248
|
+
if (!run) {
|
|
249
|
+
throw new NotFoundError(`Workflow run ${id} not found`);
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
runId: run.id,
|
|
253
|
+
status: run.status,
|
|
254
|
+
agentsSpawned: run.agentsSpawned,
|
|
255
|
+
inputTokens: run.inputTokens,
|
|
256
|
+
outputTokens: run.outputTokens,
|
|
257
|
+
// Only agent leaves; the live `workflow_leaf_*` stream is emitted solely for
|
|
258
|
+
// agent leaves (nested workflow resolutions never reach it), so the
|
|
259
|
+
// backfilled set must match. `kind: "workflow"` entries carry no label and
|
|
260
|
+
// would render as phantom unlabeled nodes.
|
|
261
|
+
leaves: deps
|
|
262
|
+
.getJournal(id)
|
|
263
|
+
.filter((entry) => entry.kind === "agent")
|
|
264
|
+
.map(toWireLeaf),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
159
268
|
function handleAbortRun(id: string) {
|
|
160
269
|
// status() is the source of truth for existence; abort() itself is a no-op
|
|
161
270
|
// for unknown/finished runs, so 404 on an unknown id is surfaced here.
|
|
@@ -271,6 +380,27 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
271
380
|
additionalResponses: { "404": { description: "Run not found" } },
|
|
272
381
|
handler: ({ pathParams }: RouteHandlerArgs) => handleGetRun(pathParams!.id),
|
|
273
382
|
},
|
|
383
|
+
{
|
|
384
|
+
operationId: "getWorkflowRunJournal",
|
|
385
|
+
endpoint: "workflows/runs/:id/journal",
|
|
386
|
+
method: "GET",
|
|
387
|
+
// Unlike the run list/status routes (settings metadata), the journal returns
|
|
388
|
+
// per-leaf prompt/result summaries — conversation/work-product content — so it
|
|
389
|
+
// requires `chat.read`, matching the subagent-detail route (the analogous
|
|
390
|
+
// content surface), not the `settings.read` used by the management routes.
|
|
391
|
+
policy: {
|
|
392
|
+
requiredScopes: ["chat.read"],
|
|
393
|
+
allowedPrincipalTypes: ACTOR_PRINCIPALS,
|
|
394
|
+
},
|
|
395
|
+
summary: "Get workflow run journal",
|
|
396
|
+
description:
|
|
397
|
+
"Return a workflow run's leaf journal as bounded per-leaf summaries (one entry per finished leaf).",
|
|
398
|
+
tags: ["workflows"],
|
|
399
|
+
responseBody: workflowJournalSchema,
|
|
400
|
+
additionalResponses: { "404": { description: "Run not found" } },
|
|
401
|
+
handler: ({ pathParams }: RouteHandlerArgs) =>
|
|
402
|
+
handleGetRunJournal(pathParams!.id),
|
|
403
|
+
},
|
|
274
404
|
{
|
|
275
405
|
operationId: "abortWorkflowRun",
|
|
276
406
|
endpoint: "workflows/runs/:id/abort",
|
|
@@ -16,7 +16,11 @@ import type { TrustContext } from "../daemon/trust-context.js";
|
|
|
16
16
|
import { getSqlite } from "../memory/db-connection.js";
|
|
17
17
|
import { initializeDb } from "../memory/db-init.js";
|
|
18
18
|
import type { ResolvedCapabilities } from "./capabilities.js";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
executeWorkflow,
|
|
21
|
+
extractWorkflowMeta,
|
|
22
|
+
type WorkflowLeafEvent,
|
|
23
|
+
} from "./engine.js";
|
|
20
24
|
import * as journalStore from "./journal-store.js";
|
|
21
25
|
import type { LeafResult, RunLeafOptions } from "./leaf-runner.js";
|
|
22
26
|
|
|
@@ -677,6 +681,21 @@ describe("executeWorkflow — resume", () => {
|
|
|
677
681
|
);
|
|
678
682
|
expect(journalA.map((e) => e.seq)).toEqual([0, 1, 2]);
|
|
679
683
|
});
|
|
684
|
+
|
|
685
|
+
test("a completed leaf persists its per-leaf token usage in the journal", async () => {
|
|
686
|
+
const runner = makeFakeRunner();
|
|
687
|
+
await execute("wf-leaf-tokens", `return agent("alpha");`, runner.runner);
|
|
688
|
+
|
|
689
|
+
const journal = journalStore.getJournal("wf-leaf-tokens");
|
|
690
|
+
expect(journal).toHaveLength(1);
|
|
691
|
+
// The fake runner reports 10/5 per leaf; the completed entry carries them.
|
|
692
|
+
expect(journal[0]).toMatchObject({
|
|
693
|
+
seq: 0,
|
|
694
|
+
status: "completed",
|
|
695
|
+
inputTokens: 10,
|
|
696
|
+
outputTokens: 5,
|
|
697
|
+
});
|
|
698
|
+
});
|
|
680
699
|
});
|
|
681
700
|
|
|
682
701
|
describe("executeWorkflow — schema vs tool leaf tool forwarding", () => {
|
|
@@ -1142,3 +1161,158 @@ describe("executeWorkflow — nested workflow()", () => {
|
|
|
1142
1161
|
expect(second.prompts).toEqual([]);
|
|
1143
1162
|
});
|
|
1144
1163
|
});
|
|
1164
|
+
|
|
1165
|
+
describe("executeWorkflow — onLeaf observe-only hook", () => {
|
|
1166
|
+
beforeEach(resetTables);
|
|
1167
|
+
|
|
1168
|
+
/** Run a workflow, collecting every onLeaf event. */
|
|
1169
|
+
function executeWithLeafEvents(
|
|
1170
|
+
runId: string,
|
|
1171
|
+
scriptSource: string,
|
|
1172
|
+
runner: typeof import("./leaf-runner.js").runLeaf,
|
|
1173
|
+
config: WorkflowsConfig = configWith(),
|
|
1174
|
+
signal?: AbortSignal,
|
|
1175
|
+
): {
|
|
1176
|
+
promise: ReturnType<typeof executeWorkflow>;
|
|
1177
|
+
events: WorkflowLeafEvent[];
|
|
1178
|
+
} {
|
|
1179
|
+
const events: WorkflowLeafEvent[] = [];
|
|
1180
|
+
const promise = executeWorkflow({
|
|
1181
|
+
runId,
|
|
1182
|
+
scriptSource: META + scriptSource,
|
|
1183
|
+
args: null,
|
|
1184
|
+
capabilities: CAPABILITIES,
|
|
1185
|
+
config,
|
|
1186
|
+
journal: journalStore,
|
|
1187
|
+
leafRunner: runner,
|
|
1188
|
+
trustContext: TRUST,
|
|
1189
|
+
onLeaf: (e) => events.push(e),
|
|
1190
|
+
...(signal ? { signal } : {}),
|
|
1191
|
+
});
|
|
1192
|
+
return { promise, events };
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
test("a single agent() emits leaf_started then leaf_finished:completed at seq 0", async () => {
|
|
1196
|
+
const fake = makeFakeRunner();
|
|
1197
|
+
const { promise, events } = executeWithLeafEvents(
|
|
1198
|
+
"wf-leaf-single",
|
|
1199
|
+
`return agent("solo");`,
|
|
1200
|
+
fake.runner,
|
|
1201
|
+
);
|
|
1202
|
+
const res = await promise;
|
|
1203
|
+
expect(res.status).toBe("completed");
|
|
1204
|
+
|
|
1205
|
+
expect(events).toEqual([
|
|
1206
|
+
{ type: "leaf_started", seq: 0, promptSummary: "solo" },
|
|
1207
|
+
{
|
|
1208
|
+
type: "leaf_finished",
|
|
1209
|
+
seq: 0,
|
|
1210
|
+
status: "completed",
|
|
1211
|
+
inputTokens: 10,
|
|
1212
|
+
outputTokens: 5,
|
|
1213
|
+
resultSummary: "out:solo",
|
|
1214
|
+
},
|
|
1215
|
+
]);
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
test("a 3-spec parallel() emits 3 started + 3 finished across seqs 0,1,2", async () => {
|
|
1219
|
+
const fake = makeFakeRunner();
|
|
1220
|
+
const { promise, events } = executeWithLeafEvents(
|
|
1221
|
+
"wf-leaf-parallel",
|
|
1222
|
+
`return parallel([leaf("a"), leaf("b"), leaf("c")]);`,
|
|
1223
|
+
fake.runner,
|
|
1224
|
+
);
|
|
1225
|
+
const res = await promise;
|
|
1226
|
+
expect(res.status).toBe("completed");
|
|
1227
|
+
|
|
1228
|
+
const started = events.filter((e) => e.type === "leaf_started");
|
|
1229
|
+
const finished = events.filter((e) => e.type === "leaf_finished");
|
|
1230
|
+
expect(started.map((e) => e.seq).sort((a, b) => a - b)).toEqual([0, 1, 2]);
|
|
1231
|
+
expect(finished.map((e) => e.seq).sort((a, b) => a - b)).toEqual([0, 1, 2]);
|
|
1232
|
+
expect(finished.every((e) => e.status === "completed")).toBe(true);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
test("a throwing (non-abort) leaf emits leaf_started then leaf_finished:failed", async () => {
|
|
1236
|
+
const fake = makeFakeRunner({ failOn: (p) => p === "boom" });
|
|
1237
|
+
const { promise, events } = executeWithLeafEvents(
|
|
1238
|
+
"wf-leaf-fail",
|
|
1239
|
+
`return parallel([leaf("boom")]);`,
|
|
1240
|
+
fake.runner,
|
|
1241
|
+
);
|
|
1242
|
+
await promise;
|
|
1243
|
+
|
|
1244
|
+
expect(events).toEqual([
|
|
1245
|
+
{ type: "leaf_started", seq: 0, promptSummary: "boom" },
|
|
1246
|
+
{
|
|
1247
|
+
type: "leaf_finished",
|
|
1248
|
+
seq: 0,
|
|
1249
|
+
status: "failed",
|
|
1250
|
+
inputTokens: 0,
|
|
1251
|
+
outputTokens: 0,
|
|
1252
|
+
resultSummary: "leaf failed: boom",
|
|
1253
|
+
},
|
|
1254
|
+
]);
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
test("an aborted in-flight leaf emits leaf_started but NO leaf_finished", async () => {
|
|
1258
|
+
const controller = new AbortController();
|
|
1259
|
+
let started = false;
|
|
1260
|
+
const runner = (async (): Promise<
|
|
1261
|
+
import("./leaf-runner.js").LeafResult
|
|
1262
|
+
> => {
|
|
1263
|
+
started = true;
|
|
1264
|
+
await new Promise<void>((resolve) => {
|
|
1265
|
+
if (controller.signal.aborted) return resolve();
|
|
1266
|
+
controller.signal.addEventListener("abort", () => resolve(), {
|
|
1267
|
+
once: true,
|
|
1268
|
+
});
|
|
1269
|
+
});
|
|
1270
|
+
const err = new Error("The operation was aborted");
|
|
1271
|
+
err.name = "AbortError";
|
|
1272
|
+
throw err;
|
|
1273
|
+
}) as unknown as typeof import("./leaf-runner.js").runLeaf;
|
|
1274
|
+
|
|
1275
|
+
const { promise, events } = executeWithLeafEvents(
|
|
1276
|
+
"wf-leaf-abort",
|
|
1277
|
+
`return agent("long-running");`,
|
|
1278
|
+
runner,
|
|
1279
|
+
configWith(),
|
|
1280
|
+
controller.signal,
|
|
1281
|
+
);
|
|
1282
|
+
|
|
1283
|
+
await waitUntil(() => started);
|
|
1284
|
+
controller.abort();
|
|
1285
|
+
|
|
1286
|
+
const res = await promise;
|
|
1287
|
+
expect(res.status).toBe("aborted");
|
|
1288
|
+
expect(events).toEqual([
|
|
1289
|
+
{ type: "leaf_started", seq: 0, promptSummary: "long-running" },
|
|
1290
|
+
]);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
test("a journal-replayed leaf emits neither leaf_started nor leaf_finished", async () => {
|
|
1294
|
+
const script = `return agent("alpha");`;
|
|
1295
|
+
|
|
1296
|
+
// First run journals the leaf as completed.
|
|
1297
|
+
const first = makeFakeRunner();
|
|
1298
|
+
const res1 = await executeWithLeafEvents(
|
|
1299
|
+
"wf-leaf-replay",
|
|
1300
|
+
script,
|
|
1301
|
+
first.runner,
|
|
1302
|
+
).promise;
|
|
1303
|
+
expect(res1.status).toBe("completed");
|
|
1304
|
+
expect(first.prompts).toEqual(["alpha"]);
|
|
1305
|
+
|
|
1306
|
+
// Resume: the leaf replays from the journal, so no onLeaf events fire.
|
|
1307
|
+
const second = makeFakeRunner();
|
|
1308
|
+
const { promise, events } = executeWithLeafEvents(
|
|
1309
|
+
"wf-leaf-replay",
|
|
1310
|
+
script,
|
|
1311
|
+
second.runner,
|
|
1312
|
+
);
|
|
1313
|
+
const res2 = await promise;
|
|
1314
|
+
expect(res2.status).toBe("completed");
|
|
1315
|
+
expect(second.prompts).toEqual([]);
|
|
1316
|
+
expect(events).toEqual([]);
|
|
1317
|
+
});
|
|
1318
|
+
});
|