stagent 0.9.2 → 0.9.5

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.
Files changed (50) hide show
  1. package/dist/cli.js +36 -1
  2. package/docs/superpowers/specs/2026-04-06-workflow-intelligence-stack-design.md +388 -0
  3. package/package.json +1 -1
  4. package/src/app/api/license/route.ts +3 -2
  5. package/src/app/api/workflows/[id]/debug/route.ts +18 -0
  6. package/src/app/api/workflows/[id]/execute/route.ts +39 -8
  7. package/src/app/api/workflows/optimize/route.ts +30 -0
  8. package/src/app/layout.tsx +4 -2
  9. package/src/components/chat/chat-message-markdown.tsx +78 -3
  10. package/src/components/chat/chat-message.tsx +12 -4
  11. package/src/components/settings/cloud-account-section.tsx +14 -12
  12. package/src/components/workflows/error-timeline.tsx +83 -0
  13. package/src/components/workflows/step-live-metrics.tsx +182 -0
  14. package/src/components/workflows/step-progress-bar.tsx +77 -0
  15. package/src/components/workflows/workflow-debug-panel.tsx +192 -0
  16. package/src/components/workflows/workflow-optimizer-panel.tsx +227 -0
  17. package/src/lib/agents/claude-agent.ts +4 -4
  18. package/src/lib/agents/runtime/anthropic-direct.ts +3 -3
  19. package/src/lib/agents/runtime/catalog.ts +30 -1
  20. package/src/lib/agents/runtime/openai-direct.ts +3 -3
  21. package/src/lib/billing/products.ts +6 -6
  22. package/src/lib/book/chapter-mapping.ts +6 -0
  23. package/src/lib/book/content.ts +10 -0
  24. package/src/lib/book/reading-paths.ts +1 -1
  25. package/src/lib/chat/__tests__/engine-stream-helpers.test.ts +57 -0
  26. package/src/lib/chat/engine.ts +68 -7
  27. package/src/lib/chat/stagent-tools.ts +2 -0
  28. package/src/lib/chat/tools/runtime-tools.ts +28 -0
  29. package/src/lib/chat/tools/schedule-tools.ts +44 -1
  30. package/src/lib/chat/tools/settings-tools.ts +40 -10
  31. package/src/lib/chat/tools/workflow-tools.ts +93 -4
  32. package/src/lib/chat/types.ts +21 -0
  33. package/src/lib/data/clear.ts +3 -0
  34. package/src/lib/db/bootstrap.ts +38 -0
  35. package/src/lib/db/migrations/0022_workflow_intelligence_phase1.sql +5 -0
  36. package/src/lib/db/migrations/0023_add_execution_stats.sql +15 -0
  37. package/src/lib/db/schema.ts +41 -1
  38. package/src/lib/license/__tests__/manager.test.ts +64 -0
  39. package/src/lib/license/manager.ts +80 -25
  40. package/src/lib/schedules/__tests__/interval-parser.test.ts +87 -0
  41. package/src/lib/schedules/__tests__/prompt-analyzer.test.ts +51 -0
  42. package/src/lib/schedules/interval-parser.ts +187 -0
  43. package/src/lib/schedules/prompt-analyzer.ts +87 -0
  44. package/src/lib/schedules/scheduler.ts +179 -9
  45. package/src/lib/workflows/cost-estimator.ts +141 -0
  46. package/src/lib/workflows/engine.ts +245 -45
  47. package/src/lib/workflows/error-analysis.ts +249 -0
  48. package/src/lib/workflows/execution-stats.ts +252 -0
  49. package/src/lib/workflows/optimizer.ts +193 -0
  50. package/src/lib/workflows/types.ts +6 -0
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { paragraphSeparator, inlineScreenshotMarkdown } from "../engine";
3
+
4
+ describe("paragraphSeparator", () => {
5
+ it("returns empty string when fullText is empty", () => {
6
+ expect(paragraphSeparator("")).toBe("");
7
+ });
8
+
9
+ it("returns two newlines between adjacent text blocks ending without a newline", () => {
10
+ expect(paragraphSeparator("review the app.")).toBe("\n\n");
11
+ });
12
+
13
+ it("returns empty string when fullText already ends with a newline", () => {
14
+ expect(paragraphSeparator("first paragraph.\n")).toBe("");
15
+ expect(paragraphSeparator("first paragraph.\n\n")).toBe("");
16
+ });
17
+ });
18
+
19
+ describe("inlineScreenshotMarkdown", () => {
20
+ it("emits markdown image with leading paragraph break when prose precedes it", () => {
21
+ const md = inlineScreenshotMarkdown(
22
+ "Let me take a screenshot of the dashboard.",
23
+ "/screenshots/thumb-1.png"
24
+ );
25
+ expect(md).toBe("\n\n![screenshot](/screenshots/thumb-1.png)\n\n");
26
+ });
27
+
28
+ it("omits the leading break when fullText is empty", () => {
29
+ const md = inlineScreenshotMarkdown("", "/screenshots/thumb-1.png");
30
+ expect(md).toBe("![screenshot](/screenshots/thumb-1.png)\n\n");
31
+ });
32
+
33
+ it("omits the leading break when fullText already ends with a newline", () => {
34
+ const md = inlineScreenshotMarkdown(
35
+ "intro paragraph.\n\n",
36
+ "/screenshots/thumb-2.png"
37
+ );
38
+ expect(md).toBe("![screenshot](/screenshots/thumb-2.png)\n\n");
39
+ });
40
+
41
+ it("simulates a streaming sequence: text → screenshot → text stays well-separated", () => {
42
+ let fullText = "";
43
+ // first text block
44
+ fullText += "Now let me take a screenshot of the dashboard.";
45
+ // screenshot capture
46
+ const inline = inlineScreenshotMarkdown(fullText, "/screenshots/thumb-1.png");
47
+ fullText += inline;
48
+ // next text block (after a tool_use turn break) — engine would inject a
49
+ // paragraph separator on content_block_start before appending.
50
+ fullText += paragraphSeparator(fullText);
51
+ fullText += "Good, I can see the dashboard.";
52
+
53
+ expect(fullText).toBe(
54
+ "Now let me take a screenshot of the dashboard.\n\n![screenshot](/screenshots/thumb-1.png)\n\nGood, I can see the dashboard."
55
+ );
56
+ });
57
+ });
@@ -102,6 +102,30 @@ function diagnoseProcessError(rawMessage: string, stderr: string): string {
102
102
  return rawMessage;
103
103
  }
104
104
 
105
+ // ── Stream-shaping helpers (exported for unit tests) ──────────────────
106
+
107
+ /**
108
+ * Returns the separator to insert before appending new text to `fullText`.
109
+ * The Anthropic stream delivers text in `content_block`s with no trailing
110
+ * newline, so adjacent blocks (e.g. before/after a tool_use turn break) fuse
111
+ * together visually unless we inject a paragraph break.
112
+ */
113
+ export function paragraphSeparator(fullText: string): string {
114
+ return fullText.length > 0 && !fullText.endsWith("\n") ? "\n\n" : "";
115
+ }
116
+
117
+ /**
118
+ * Builds the inline markdown segment for a captured screenshot. The leading
119
+ * separator preserves paragraph spacing relative to the prose that came
120
+ * before; the trailing `\n\n` makes sure subsequent text starts a new block.
121
+ */
122
+ export function inlineScreenshotMarkdown(
123
+ fullText: string,
124
+ thumbnailUrl: string
125
+ ): string {
126
+ return `${paragraphSeparator(fullText)}![screenshot](${thumbnailUrl})\n\n`;
127
+ }
128
+
105
129
  // ── Public API ─────────────────────────────────────────────────────────
106
130
 
107
131
  /**
@@ -417,7 +441,16 @@ export async function* sendMessage(
417
441
  if (raw.type === "stream_event") {
418
442
  // SDK wraps Anthropic API events inside stream_event.event
419
443
  const innerEvent = raw.event as Record<string, unknown> | undefined;
420
- if (innerEvent?.type === "content_block_delta") {
444
+ if (innerEvent?.type === "content_block_start") {
445
+ const block = innerEvent.content_block as Record<string, unknown> | undefined;
446
+ if (block?.type === "text" && fullText.length > 0 && !fullText.endsWith("\n")) {
447
+ // New text block after a previous block (often a tool_use turn break) —
448
+ // models don't end blocks with paragraph breaks, so insert one to keep
449
+ // sequential turns visually separated in the chat bubble.
450
+ fullText += "\n\n";
451
+ yield { type: "delta", content: "\n\n" };
452
+ }
453
+ } else if (innerEvent?.type === "content_block_delta") {
421
454
  const delta = innerEvent.delta as Record<string, unknown> | undefined;
422
455
  if (delta?.type === "text_delta" && typeof delta.text === "string") {
423
456
  fullText += delta.text;
@@ -425,6 +458,12 @@ export async function* sendMessage(
425
458
  yield { type: "delta", content: delta.text };
426
459
  }
427
460
  }
461
+ } else if (raw.type === "content_block_start") {
462
+ const block = (raw as Record<string, unknown>).content_block as Record<string, unknown> | undefined;
463
+ if (block?.type === "text" && fullText.length > 0 && !fullText.endsWith("\n")) {
464
+ fullText += "\n\n";
465
+ yield { type: "delta", content: "\n\n" };
466
+ }
428
467
  } else if (raw.type === "content_block_delta") {
429
468
  const delta = raw.delta as Record<string, unknown> | undefined;
430
469
  if (delta?.type === "text_delta" && typeof delta.text === "string") {
@@ -457,6 +496,10 @@ export async function* sendMessage(
457
496
  if (assistantBlocks) {
458
497
  for (const block of assistantBlocks) {
459
498
  if (block.type === "text" && typeof block.text === "string" && !fullText.includes(block.text)) {
499
+ if (fullText.length > 0 && !fullText.endsWith("\n")) {
500
+ fullText += "\n\n";
501
+ yield { type: "delta", content: "\n\n" };
502
+ }
460
503
  fullText += block.text;
461
504
  yield { type: "delta", content: block.text };
462
505
  }
@@ -486,6 +529,16 @@ export async function* sendMessage(
486
529
  if (attachment) {
487
530
  screenshotAttachments.push(attachment);
488
531
  yield { type: "screenshot" as const, ...attachment };
532
+ // Also inject the screenshot inline into the text stream as a
533
+ // markdown image so it renders next to the prose that captured
534
+ // it (the markdown renderer resolves the thumbnail src back to
535
+ // the full attachment via the message's metadata.attachments).
536
+ const inlineMd = inlineScreenshotMarkdown(
537
+ fullText,
538
+ attachment.thumbnailUrl
539
+ );
540
+ fullText += inlineMd;
541
+ yield { type: "delta" as const, content: inlineMd };
489
542
  }
490
543
  }
491
544
  }
@@ -513,13 +566,21 @@ export async function* sendMessage(
513
566
  const result = (raw as Record<string, unknown>).result;
514
567
  if (typeof result === "string" && result.length > 0) {
515
568
  if (result !== fullText) {
516
- const remainder = result.startsWith(fullText)
517
- ? result.slice(fullText.length)
518
- : result;
519
- if (remainder) {
520
- yield { type: "delta" as const, content: remainder };
569
+ if (result.startsWith(fullText)) {
570
+ const remainder = result.slice(fullText.length);
571
+ if (remainder) {
572
+ yield { type: "delta" as const, content: remainder };
573
+ }
574
+ } else {
575
+ // Result is unrelated to what we have so far — treat as a new
576
+ // text block and insert a paragraph break before appending.
577
+ if (fullText.length > 0 && !fullText.endsWith("\n")) {
578
+ yield { type: "delta" as const, content: "\n\n" };
579
+ fullText += "\n\n";
580
+ }
581
+ yield { type: "delta" as const, content: result };
521
582
  }
522
- fullText = result;
583
+ fullText = result.startsWith(fullText) ? result : fullText + result;
523
584
  }
524
585
  }
525
586
  }
@@ -20,6 +20,7 @@ import { settingsTools } from "./tools/settings-tools";
20
20
  import { chatHistoryTools } from "./tools/chat-history-tools";
21
21
  import { handoffTools } from "./tools/handoff-tools";
22
22
  import { tableTools } from "./tools/table-tools";
23
+ import { runtimeTools } from "./tools/runtime-tools";
23
24
 
24
25
  // ── Tool server types ────────────────────────────────────────────────
25
26
 
@@ -54,6 +55,7 @@ function collectAllTools(ctx: ToolContext): ToolDefinition[] {
54
55
  ...chatHistoryTools(ctx),
55
56
  ...handoffTools(ctx),
56
57
  ...tableTools(ctx),
58
+ ...runtimeTools(ctx),
57
59
  ];
58
60
  }
59
61
 
@@ -0,0 +1,28 @@
1
+ import { defineTool } from "../tool-registry";
2
+ import { ok, type ToolContext } from "./helpers";
3
+ import { listRuntimeCatalog } from "@/lib/agents/runtime/catalog";
4
+
5
+ export function runtimeTools(_ctx: ToolContext) {
6
+ return [
7
+ defineTool(
8
+ "list_runtimes",
9
+ "List all available AI runtimes with their models and capabilities. Use this to discover which runtimes can be assigned to workflows.",
10
+ {},
11
+ async () => {
12
+ const catalog = listRuntimeCatalog();
13
+ return ok(
14
+ catalog.map((entry) => ({
15
+ id: entry.id,
16
+ label: entry.label,
17
+ provider: entry.providerId,
18
+ description: entry.description,
19
+ models: entry.models,
20
+ capabilities: Object.entries(entry.capabilities)
21
+ .filter(([, v]) => v)
22
+ .map(([k]) => k),
23
+ }))
24
+ );
25
+ }
26
+ ),
27
+ ];
28
+ }
@@ -4,6 +4,7 @@ import { db } from "@/lib/db";
4
4
  import { schedules } from "@/lib/db/schema";
5
5
  import { eq, and, desc } from "drizzle-orm";
6
6
  import { ok, err, type ToolContext } from "./helpers";
7
+ import { analyzePromptEfficiency } from "@/lib/schedules/prompt-analyzer";
7
8
 
8
9
  const VALID_SCHEDULE_STATUSES = [
9
10
  "active",
@@ -97,6 +98,39 @@ export function scheduleTools(ctx: ToolContext) {
97
98
  const effectiveProjectId = args.projectId ?? ctx.projectId ?? null;
98
99
  const now = new Date();
99
100
  const id = crypto.randomUUID();
101
+
102
+ // Auto-stagger: if other active schedules in this project would
103
+ // collide with the requested cron, offset its minute field. We scope
104
+ // to the same project so unrelated workspaces don't interfere.
105
+ const { computeStaggeredCron } = await import(
106
+ "@/lib/schedules/interval-parser"
107
+ );
108
+ const existing = await db
109
+ .select({ cron: schedules.cronExpression })
110
+ .from(schedules)
111
+ .where(
112
+ effectiveProjectId
113
+ ? and(
114
+ eq(schedules.status, "active"),
115
+ eq(schedules.projectId, effectiveProjectId)
116
+ )
117
+ : eq(schedules.status, "active")
118
+ );
119
+ const staggerResult = computeStaggeredCron(
120
+ cronExpression,
121
+ existing.map((s) => s.cron)
122
+ );
123
+ if (staggerResult.offsetApplied > 0) {
124
+ console.log(
125
+ `[scheduler] staggered "${args.name}" by ${staggerResult.offsetApplied}min to avoid collision (${cronExpression} → ${staggerResult.cronExpression})`
126
+ );
127
+ cronExpression = staggerResult.cronExpression;
128
+ }
129
+
130
+ // Surface prompt-efficiency warnings before creating the schedule.
131
+ // We still create the schedule — these are guidance, not blockers.
132
+ const warnings = analyzePromptEfficiency(args.prompt);
133
+
100
134
  const nextFireAt = computeNextFireTime(cronExpression, now);
101
135
  const expiresAt = args.expiresInHours
102
136
  ? new Date(now.getTime() + args.expiresInHours * 60 * 60 * 1000)
@@ -126,7 +160,16 @@ export function scheduleTools(ctx: ToolContext) {
126
160
  .where(eq(schedules.id, id));
127
161
 
128
162
  ctx.onToolResult?.("create_schedule", schedule);
129
- return ok(schedule);
163
+ return ok({
164
+ schedule,
165
+ warnings,
166
+ staggered: staggerResult.offsetApplied > 0
167
+ ? {
168
+ offsetMinutes: staggerResult.offsetApplied,
169
+ originalCron: staggerResult.collided ? args.interval : undefined,
170
+ }
171
+ : undefined,
172
+ });
130
173
  } catch (e) {
131
174
  return err(e instanceof Error ? e.message : "Failed to create schedule");
132
175
  }
@@ -64,6 +64,29 @@ const WRITABLE_SETTINGS: Record<string, WritableSetting> = {
64
64
  validate: (v) =>
65
65
  v.trim().length === 0 ? "Must be non-empty string" : null,
66
66
  },
67
+ "budget_max_cost_per_task": {
68
+ description: "Max cost per task in USD (0.5–50)",
69
+ validate: (v) => {
70
+ const n = parseFloat(v);
71
+ return isNaN(n) || n < 0.5 || n > 50 ? "Must be number 0.5–50" : null;
72
+ },
73
+ },
74
+ "budget_max_tokens_per_task": {
75
+ description: "Max tokens per task (1000–500000)",
76
+ validate: (v) => {
77
+ const n = parseInt(v, 10);
78
+ return isNaN(n) || n < 1000 || n > 500000
79
+ ? "Must be integer 1000–500000"
80
+ : null;
81
+ },
82
+ },
83
+ "budget_max_daily_cost": {
84
+ description: "Max daily spend in USD (1–500)",
85
+ validate: (v) => {
86
+ const n = parseFloat(v);
87
+ return isNaN(n) || n < 1 || n > 500 ? "Must be number 1–500" : null;
88
+ },
89
+ },
67
90
  };
68
91
 
69
92
  const WRITABLE_KEYS_DOC = Object.entries(WRITABLE_SETTINGS)
@@ -109,10 +132,14 @@ export function settingsTools(_ctx: ToolContext) {
109
132
 
110
133
  if (args.key) {
111
134
  const value = await getSetting(args.key);
112
- return ok({ key: args.key, value });
135
+ return ok({
136
+ key: args.key,
137
+ value,
138
+ writable: args.key in WRITABLE_SETTINGS,
139
+ });
113
140
  }
114
141
 
115
- // Return common settings + workspace context
142
+ // Return common settings + workspace context with writability tags
116
143
  const keys = [
117
144
  "auth_method",
118
145
  "default_runtime",
@@ -121,17 +148,20 @@ export function settingsTools(_ctx: ToolContext) {
121
148
  "budget_max_cost_per_task",
122
149
  "budget_max_daily_cost",
123
150
  ];
124
- const entries: Record<string, string | null> = {};
151
+ const entries: Record<string, { value: string | null; writable: boolean }> = {};
125
152
  for (const key of keys) {
126
- entries[key] = await getSetting(key);
153
+ entries[key] = {
154
+ value: await getSetting(key),
155
+ writable: key in WRITABLE_SETTINGS,
156
+ };
127
157
  }
128
158
 
129
- // Append workspace context
159
+ // Append workspace context (read-only)
130
160
  const ws = getWorkspaceContext();
131
- entries.workspace_cwd = ws.cwd;
132
- entries.workspace_git_branch = ws.gitBranch;
133
- entries.workspace_is_worktree = ws.isWorktree ? "true" : "false";
134
- entries.workspace_folder_name = ws.folderName;
161
+ entries.workspace_cwd = { value: ws.cwd, writable: false };
162
+ entries.workspace_git_branch = { value: ws.gitBranch, writable: false };
163
+ entries.workspace_is_worktree = { value: ws.isWorktree ? "true" : "false", writable: false };
164
+ entries.workspace_folder_name = { value: ws.folderName, writable: false };
135
165
 
136
166
  return ok(entries);
137
167
  } catch (e) {
@@ -151,7 +181,7 @@ export function settingsTools(_ctx: ToolContext) {
151
181
  const spec = WRITABLE_SETTINGS[args.key];
152
182
  if (!spec) {
153
183
  return err(
154
- `Key "${args.key}" is not writable. Valid keys: ${Object.keys(WRITABLE_SETTINGS).join(", ")}`
184
+ `Key "${args.key}" is not writable via set_settings. Use get_settings to see which keys are writable (writable: true). Writable keys: ${Object.keys(WRITABLE_SETTINGS).join(", ")}`
155
185
  );
156
186
  }
157
187
  const validationError = spec.validate(args.value);
@@ -87,6 +87,12 @@ export function workflowTools(ctx: ToolContext) {
87
87
  .describe(
88
88
  "Optional array of document IDs from the project pool to attach as input context. These documents will be injected into all workflow steps at execution time."
89
89
  ),
90
+ runtime: z
91
+ .string()
92
+ .optional()
93
+ .describe(
94
+ "Runtime to use for workflow execution (e.g., 'openai-direct', 'anthropic-direct'). Use list_runtimes to see available options. Omit to use the system default."
95
+ ),
90
96
  },
91
97
  async (args) => {
92
98
  try {
@@ -110,6 +116,16 @@ export function workflowTools(ctx: ToolContext) {
110
116
  }
111
117
  args.definition = JSON.stringify(parsedDef);
112
118
 
119
+ // Validate runtime if provided
120
+ let runtimeId: string | null = null;
121
+ if (args.runtime) {
122
+ const { isAgentRuntimeId } = await import("@/lib/agents/runtime/catalog");
123
+ if (!isAgentRuntimeId(args.runtime)) {
124
+ return err(`Invalid runtime "${args.runtime}". Use list_runtimes to see available options.`);
125
+ }
126
+ runtimeId = args.runtime;
127
+ }
128
+
113
129
  const effectiveProjectId = args.projectId ?? ctx.projectId ?? null;
114
130
  const now = new Date();
115
131
  const id = crypto.randomUUID();
@@ -119,12 +135,13 @@ export function workflowTools(ctx: ToolContext) {
119
135
  name: args.name,
120
136
  projectId: effectiveProjectId,
121
137
  definition: args.definition,
138
+ runtimeId,
122
139
  status: "draft",
123
140
  createdAt: now,
124
141
  updatedAt: now,
125
142
  });
126
143
 
127
- // Attach pool documents if provided
144
+ // Attach global pool documents if provided
128
145
  const attachedDocs: string[] = [];
129
146
  if (args.documentIds && args.documentIds.length > 0) {
130
147
  for (const docId of args.documentIds) {
@@ -143,6 +160,27 @@ export function workflowTools(ctx: ToolContext) {
143
160
  }
144
161
  }
145
162
 
163
+ // Attach per-step documents from step definitions
164
+ let stepDocCount = 0;
165
+ for (const step of parsedDef.steps) {
166
+ if (step.documentIds && Array.isArray(step.documentIds)) {
167
+ for (const docId of step.documentIds) {
168
+ try {
169
+ await db.insert(workflowDocumentInputs).values({
170
+ id: crypto.randomUUID(),
171
+ workflowId: id,
172
+ documentId: docId,
173
+ stepId: step.id,
174
+ createdAt: now,
175
+ });
176
+ stepDocCount++;
177
+ } catch {
178
+ // Skip duplicates or invalid doc IDs
179
+ }
180
+ }
181
+ }
182
+ }
183
+
146
184
  const [workflow] = await db
147
185
  .select()
148
186
  .from(workflows)
@@ -154,8 +192,10 @@ export function workflowTools(ctx: ToolContext) {
154
192
  name: workflow.name,
155
193
  projectId: workflow.projectId,
156
194
  status: workflow.status,
195
+ runtimeId: workflow.runtimeId,
157
196
  createdAt: workflow.createdAt,
158
197
  attachedDocuments: attachedDocs.length,
198
+ stepScopedDocuments: stepDocCount,
159
199
  });
160
200
  } catch (e) {
161
201
  return err(e instanceof Error ? e.message : "Failed to create workflow");
@@ -328,10 +368,59 @@ export function workflowTools(ctx: ToolContext) {
328
368
  .get();
329
369
 
330
370
  if (!workflow) return err(`Workflow not found: ${args.workflowId}`);
331
- if (workflow.status === "active")
332
- return err("Workflow is already running");
333
- if (workflow.status !== "draft" && workflow.status !== "paused" && workflow.status !== "failed")
371
+
372
+ // Allow re-execution from crashed "active" if no live tasks
373
+ if (workflow.status === "active") {
374
+ const liveTasks = await db
375
+ .select({ id: tasks.id })
376
+ .from(tasks)
377
+ .where(
378
+ and(
379
+ eq(tasks.workflowId, args.workflowId),
380
+ inArray(tasks.status, ["running", "queued"])
381
+ )
382
+ );
383
+ if (liveTasks.length > 0) {
384
+ return err("Workflow is already running");
385
+ }
386
+ // Crashed — fall through to reset + re-execute
387
+ }
388
+
389
+ if (
390
+ workflow.status !== "draft" &&
391
+ workflow.status !== "paused" &&
392
+ workflow.status !== "failed" &&
393
+ workflow.status !== "active" &&
394
+ workflow.status !== "completed"
395
+ ) {
334
396
  return err(`Cannot execute a workflow in '${workflow.status}' status`);
397
+ }
398
+
399
+ // Reset state for re-execution from non-draft status
400
+ if (workflow.status !== "draft") {
401
+ // Cancel orphaned tasks
402
+ await db
403
+ .update(tasks)
404
+ .set({ status: "cancelled", updatedAt: new Date() })
405
+ .where(
406
+ and(
407
+ eq(tasks.workflowId, args.workflowId),
408
+ inArray(tasks.status, ["running", "queued"])
409
+ )
410
+ );
411
+
412
+ // Clear execution state
413
+ const { parseWorkflowState } = await import("@/lib/workflows/engine");
414
+ const { definition } = parseWorkflowState(workflow.definition);
415
+ await db
416
+ .update(workflows)
417
+ .set({
418
+ definition: JSON.stringify(definition),
419
+ status: "draft",
420
+ updatedAt: new Date(),
421
+ })
422
+ .where(eq(workflows.id, args.workflowId));
423
+ }
335
424
 
336
425
  // Atomic claim: set to active
337
426
  await db
@@ -67,6 +67,27 @@ export const CHAT_MODELS: ChatModelOption[] = [
67
67
 
68
68
  export const DEFAULT_CHAT_MODEL = "haiku";
69
69
 
70
+ // Validate CHAT_MODELS against runtime catalog at module load
71
+ // Warns on stale model IDs that don't appear in any runtime's supported list
72
+ try {
73
+ const { listRuntimeCatalog } = require("@/lib/agents/runtime/catalog");
74
+ const allSupportedModels = new Set<string>();
75
+ for (const runtime of listRuntimeCatalog()) {
76
+ for (const model of runtime.models.supported) {
77
+ allSupportedModels.add(model);
78
+ }
79
+ }
80
+ for (const model of CHAT_MODELS) {
81
+ if (!allSupportedModels.has(model.id)) {
82
+ console.warn(
83
+ `[chat-models] CHAT_MODELS entry "${model.id}" not found in any runtime's supported models — may be stale`
84
+ );
85
+ }
86
+ }
87
+ } catch {
88
+ // Catalog not available during build/test — skip validation
89
+ }
90
+
70
91
  /** Resolve a model ID to its display label (e.g., "opus" → "Opus", "gpt-5.4" → "GPT-5.4") */
71
92
  export function resolveModelLabel(modelId: string): string {
72
93
  const model = CHAT_MODELS.find((m) => m.id === modelId);
@@ -42,6 +42,7 @@ import {
42
42
  taskTableInputs,
43
43
  workflowTableInputs,
44
44
  scheduleTableInputs,
45
+ workflowExecutionStats,
45
46
  } from "@/lib/db/schema";
46
47
  import { readdirSync, unlinkSync, mkdirSync } from "fs";
47
48
  import { join } from "path";
@@ -119,6 +120,7 @@ export function clearAllData() {
119
120
  const documentsDeleted = db.delete(documents).run().changes;
120
121
  const agentMemoryDeleted = db.delete(agentMemory).run().changes;
121
122
  const learnedContextDeleted = db.delete(learnedContext).run().changes;
123
+ const executionStatsDeleted = db.delete(workflowExecutionStats).run().changes;
122
124
  const tasksDeleted = db.delete(tasks).run().changes;
123
125
  const workflowsDeleted = db.delete(workflows).run().changes;
124
126
  const schedulesDeleted = db.delete(schedules).run().changes;
@@ -194,5 +196,6 @@ export function clearAllData() {
194
196
  files: filesDeleted,
195
197
  screenshots: screenshotsDeleted,
196
198
  license: licenseDeleted,
199
+ workflowExecutionStats: executionStatsDeleted,
197
200
  };
198
201
  }
@@ -46,6 +46,7 @@ const STAGENT_TABLES = [
46
46
  "user_table_row_history",
47
47
  "snapshots",
48
48
  "license",
49
+ "workflow_execution_stats",
49
50
  ] as const;
50
51
 
51
52
  export function bootstrapStagentDatabase(sqlite: Database.Database): void {
@@ -74,6 +75,7 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
74
75
  session_id TEXT,
75
76
  resume_count INTEGER DEFAULT 0 NOT NULL,
76
77
  workflow_run_number INTEGER,
78
+ max_budget_usd REAL,
77
79
  created_at INTEGER NOT NULL,
78
80
  updated_at INTEGER NOT NULL,
79
81
  FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE NO ACTION ON DELETE NO ACTION,
@@ -88,6 +90,7 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
88
90
  definition TEXT NOT NULL,
89
91
  status TEXT DEFAULT 'draft' NOT NULL,
90
92
  run_number INTEGER DEFAULT 0 NOT NULL,
93
+ runtime_id TEXT,
91
94
  created_at INTEGER NOT NULL,
92
95
  updated_at INTEGER NOT NULL,
93
96
  FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE NO ACTION ON DELETE NO ACTION
@@ -176,6 +179,10 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
176
179
  heartbeat_budget_per_day INTEGER,
177
180
  heartbeat_spent_today INTEGER DEFAULT 0 NOT NULL,
178
181
  heartbeat_budget_reset_at INTEGER,
182
+ avg_turns_per_firing INTEGER,
183
+ last_turn_count INTEGER,
184
+ failure_streak INTEGER DEFAULT 0 NOT NULL,
185
+ last_failure_reason TEXT,
179
186
  created_at INTEGER NOT NULL,
180
187
  updated_at INTEGER NOT NULL,
181
188
  FOREIGN KEY (project_id) REFERENCES projects(id) ON UPDATE NO ACTION ON DELETE NO ACTION
@@ -536,6 +543,13 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
536
543
  `);
537
544
 
538
545
  addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN delivery_channels TEXT;`);
546
+ // Schedule health-monitoring columns (collision-prevention feature).
547
+ // Nullable so existing rows backfill cleanly; failure_streak defaults to 0
548
+ // so the auto-pause logic treats existing schedules as "no failures yet".
549
+ addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN avg_turns_per_firing INTEGER;`);
550
+ addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN last_turn_count INTEGER;`);
551
+ addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN failure_streak INTEGER DEFAULT 0 NOT NULL;`);
552
+ addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN last_failure_reason TEXT;`);
539
553
  addColumnIfMissing(`ALTER TABLE channel_configs ADD COLUMN direction TEXT DEFAULT 'outbound' NOT NULL;`);
540
554
 
541
555
  // ── Bidirectional Channel Chat ──────────────────────────────────────────
@@ -856,7 +870,31 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
856
870
  created_at INTEGER NOT NULL,
857
871
  updated_at INTEGER NOT NULL
858
872
  );
873
+
874
+ CREATE TABLE IF NOT EXISTS workflow_execution_stats (
875
+ id TEXT PRIMARY KEY,
876
+ pattern TEXT NOT NULL,
877
+ step_count INTEGER NOT NULL,
878
+ avg_docs_per_step REAL,
879
+ avg_cost_per_step_micros INTEGER,
880
+ avg_duration_per_step_ms INTEGER,
881
+ success_rate REAL,
882
+ common_failures TEXT,
883
+ runtime_breakdown TEXT,
884
+ sample_count INTEGER NOT NULL DEFAULT 0,
885
+ last_updated TEXT NOT NULL,
886
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
887
+ );
859
888
  `);
889
+
890
+ // Safety: add columns that may be missing on existing databases.
891
+ // SQLite ALTER TABLE ADD COLUMN is a no-op if column exists (throws, caught).
892
+ for (const alter of [
893
+ "ALTER TABLE tasks ADD COLUMN max_budget_usd REAL",
894
+ "ALTER TABLE workflows ADD COLUMN runtime_id TEXT",
895
+ ]) {
896
+ try { sqlite.exec(alter); } catch { /* column already exists — expected */ }
897
+ }
860
898
  }
861
899
 
862
900
  export function hasLegacyStagentTables(sqlite: Database.Database): boolean {
@@ -0,0 +1,5 @@
1
+ -- Workflow Intelligence Stack — Phase 1 schema changes
2
+ -- Feature 1: Per-task budget cap storage
3
+ ALTER TABLE tasks ADD COLUMN max_budget_usd REAL;
4
+ -- Feature 2: Per-workflow runtime selection
5
+ ALTER TABLE workflows ADD COLUMN runtime_id TEXT;
@@ -0,0 +1,15 @@
1
+ -- Workflow Intelligence Stack — Phase 2: Execution learning
2
+ CREATE TABLE IF NOT EXISTS workflow_execution_stats (
3
+ id TEXT PRIMARY KEY,
4
+ pattern TEXT NOT NULL,
5
+ step_count INTEGER NOT NULL,
6
+ avg_docs_per_step REAL,
7
+ avg_cost_per_step_micros INTEGER,
8
+ avg_duration_per_step_ms INTEGER,
9
+ success_rate REAL,
10
+ common_failures TEXT,
11
+ runtime_breakdown TEXT,
12
+ sample_count INTEGER NOT NULL DEFAULT 0,
13
+ last_updated TEXT NOT NULL,
14
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
15
+ );