stagent 0.9.5 → 0.10.0
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/README.md +5 -42
- package/dist/cli.js +42 -18
- package/docs/.coverage-gaps.json +13 -55
- package/docs/.last-generated +1 -1
- package/docs/features/provider-runtimes.md +4 -0
- package/docs/features/schedules.md +32 -4
- package/docs/features/settings.md +28 -5
- package/docs/features/tables.md +9 -2
- package/docs/features/workflows.md +10 -4
- package/docs/journeys/developer.md +15 -1
- package/docs/journeys/personal-use.md +21 -4
- package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
- package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
- package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
- package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
- package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
- package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
- package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
- package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
- package/package.json +3 -2
- package/src/__tests__/instrumentation-smoke.test.ts +15 -0
- package/src/app/analytics/page.tsx +1 -21
- package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
- package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
- package/src/app/api/instance/config/route.ts +41 -0
- package/src/app/api/instance/init/route.ts +34 -0
- package/src/app/api/instance/upgrade/check/route.ts +26 -0
- package/src/app/api/instance/upgrade/route.ts +96 -0
- package/src/app/api/instance/upgrade/status/route.ts +35 -0
- package/src/app/api/memory/route.ts +0 -11
- package/src/app/api/notifications/route.ts +4 -2
- package/src/app/api/projects/[id]/route.ts +5 -155
- package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
- package/src/app/api/schedules/[id]/execute/route.ts +111 -0
- package/src/app/api/schedules/[id]/route.ts +9 -1
- package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
- package/src/app/api/schedules/route.ts +3 -12
- package/src/app/api/settings/openai/login/route.ts +22 -0
- package/src/app/api/settings/openai/logout/route.ts +7 -0
- package/src/app/api/settings/openai/route.ts +21 -1
- package/src/app/api/settings/providers/route.ts +35 -8
- package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
- package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
- package/src/app/api/tables/[id]/enrich/route.ts +147 -0
- package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
- package/src/app/api/tasks/[id]/execute/route.ts +0 -21
- package/src/app/api/workflows/[id]/resume/route.ts +59 -0
- package/src/app/api/workflows/[id]/status/route.ts +22 -8
- package/src/app/api/workspace/context/route.ts +2 -0
- package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
- package/src/app/chat/page.tsx +11 -0
- package/src/app/inbox/page.tsx +12 -5
- package/src/app/layout.tsx +42 -21
- package/src/app/page.tsx +0 -2
- package/src/app/settings/page.tsx +6 -9
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +408 -0
- package/src/components/chat/chat-command-popover.tsx +2 -2
- package/src/components/chat/chat-input.tsx +2 -3
- package/src/components/chat/chat-session-provider.tsx +720 -0
- package/src/components/chat/chat-shell.tsx +92 -401
- package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
- package/src/components/instance/instance-section.tsx +382 -0
- package/src/components/instance/upgrade-badge.tsx +219 -0
- package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
- package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
- package/src/components/notifications/batch-proposal-review.tsx +20 -5
- package/src/components/notifications/inbox-list.tsx +11 -2
- package/src/components/notifications/notification-item.tsx +56 -2
- package/src/components/notifications/pending-approval-host.tsx +56 -37
- package/src/components/schedules/schedule-create-sheet.tsx +19 -1
- package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
- package/src/components/schedules/schedule-form.tsx +31 -0
- package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
- package/src/components/settings/auth-method-selector.tsx +19 -4
- package/src/components/settings/auth-status-badge.tsx +28 -3
- package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
- package/src/components/settings/openai-runtime-section.tsx +7 -1
- package/src/components/settings/providers-runtimes-section.tsx +138 -19
- package/src/components/shared/app-sidebar.tsx +4 -3
- package/src/components/shared/command-palette.tsx +4 -5
- package/src/components/shared/theme-toggle.tsx +5 -24
- package/src/components/shared/workspace-indicator.tsx +61 -2
- package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
- package/src/components/tables/table-create-sheet.tsx +4 -0
- package/src/components/tables/table-enrichment-runs.tsx +103 -0
- package/src/components/tables/table-enrichment-sheet.tsx +538 -0
- package/src/components/tables/table-spreadsheet.tsx +29 -5
- package/src/components/tables/table-toolbar.tsx +10 -1
- package/src/components/tasks/kanban-board.tsx +1 -0
- package/src/components/tasks/kanban-column.tsx +53 -14
- package/src/components/tasks/task-bento-grid.tsx +19 -0
- package/src/components/tasks/task-card.tsx +26 -3
- package/src/components/tasks/task-chip-bar.tsx +24 -0
- package/src/components/tasks/task-result-renderer.tsx +1 -1
- package/src/components/workflows/delay-step-body.tsx +109 -0
- package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
- package/src/components/workflows/loop-status-view.tsx +1 -1
- package/src/components/workflows/shared/step-result.tsx +78 -0
- package/src/components/workflows/shared/workflow-header.tsx +141 -0
- package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
- package/src/components/workflows/swarm-dashboard.tsx +2 -15
- package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
- package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
- package/src/components/workflows/workflow-form-view.tsx +133 -16
- package/src/components/workflows/workflow-status-view.tsx +30 -740
- package/src/instrumentation-node.ts +94 -0
- package/src/instrumentation.ts +4 -48
- package/src/lib/agents/__tests__/claude-agent.test.ts +199 -0
- package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
- package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
- package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
- package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
- package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
- package/src/lib/agents/claude-agent.ts +155 -18
- package/src/lib/agents/execution-manager.ts +0 -35
- package/src/lib/agents/learned-context.ts +0 -12
- package/src/lib/agents/learning-session.ts +18 -5
- package/src/lib/agents/profiles/__tests__/registry.test.ts +6 -4
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +70 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +32 -0
- package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
- package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
- package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
- package/src/lib/agents/runtime/openai-codex.ts +29 -60
- package/src/lib/agents/runtime/types.ts +8 -0
- package/src/lib/book/chapter-mapping.ts +11 -0
- package/src/lib/book/content.ts +10 -0
- package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
- package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
- package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
- package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
- package/src/lib/chat/active-streams.ts +27 -0
- package/src/lib/chat/codex-engine.ts +16 -17
- package/src/lib/chat/context-builder.ts +5 -3
- package/src/lib/chat/engine.ts +50 -3
- package/src/lib/chat/reconcile.ts +117 -0
- package/src/lib/chat/stagent-tools.ts +1 -0
- package/src/lib/chat/stream-telemetry.ts +132 -0
- package/src/lib/chat/suggested-prompts.ts +28 -1
- package/src/lib/chat/system-prompt.ts +26 -1
- package/src/lib/chat/tool-catalog.ts +2 -1
- package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
- package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +352 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +217 -0
- package/src/lib/chat/tools/document-tools.ts +29 -13
- package/src/lib/chat/tools/helpers.ts +39 -0
- package/src/lib/chat/tools/notification-tools.ts +9 -5
- package/src/lib/chat/tools/project-tools.ts +33 -0
- package/src/lib/chat/tools/schedule-tools.ts +44 -11
- package/src/lib/chat/tools/table-tools.ts +71 -0
- package/src/lib/chat/tools/task-tools.ts +84 -20
- package/src/lib/chat/tools/workflow-tools.ts +234 -32
- package/src/lib/constants/settings.ts +8 -18
- package/src/lib/data/__tests__/clear.test.ts +56 -2
- package/src/lib/data/clear.ts +20 -15
- package/src/lib/data/delete-project.ts +171 -0
- package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
- package/src/lib/db/bootstrap.ts +45 -16
- package/src/lib/db/index.ts +5 -0
- package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
- package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
- package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
- package/src/lib/db/migrations/0026_drop_license.sql +3 -0
- package/src/lib/db/migrations/meta/_journal.json +21 -0
- package/src/lib/db/schema.ts +68 -23
- package/src/lib/environment/workspace-context.ts +13 -1
- package/src/lib/import/dedup.ts +4 -54
- package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
- package/src/lib/instance/__tests__/detect.test.ts +115 -0
- package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
- package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
- package/src/lib/instance/__tests__/settings.test.ts +83 -0
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +131 -0
- package/src/lib/instance/bootstrap.ts +270 -0
- package/src/lib/instance/detect.ts +49 -0
- package/src/lib/instance/fingerprint.ts +78 -0
- package/src/lib/instance/git-ops.ts +95 -0
- package/src/lib/instance/settings.ts +61 -0
- package/src/lib/instance/types.ts +77 -0
- package/src/lib/instance/upgrade-poller.ts +153 -0
- package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
- package/src/lib/notifications/visibility.ts +33 -0
- package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
- package/src/lib/schedules/__tests__/config.test.ts +62 -0
- package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
- package/src/lib/schedules/__tests__/integration.test.ts +82 -0
- package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
- package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
- package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
- package/src/lib/schedules/collision-check.ts +105 -0
- package/src/lib/schedules/config.ts +53 -0
- package/src/lib/schedules/scheduler.ts +232 -13
- package/src/lib/schedules/slot-claim.ts +105 -0
- package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
- package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
- package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
- package/src/lib/settings/openai-auth.ts +105 -10
- package/src/lib/settings/openai-login-manager.ts +260 -0
- package/src/lib/settings/runtime-setup.ts +14 -4
- package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
- package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
- package/src/lib/tables/enrichment-planner.ts +454 -0
- package/src/lib/tables/enrichment.ts +328 -0
- package/src/lib/tables/query-builder.ts +5 -2
- package/src/lib/tables/trigger-evaluator.ts +3 -2
- package/src/lib/theme.ts +71 -0
- package/src/lib/usage/ledger.ts +2 -18
- package/src/lib/util/__tests__/similarity.test.ts +106 -0
- package/src/lib/util/similarity.ts +77 -0
- package/src/lib/utils/format-timestamp.ts +24 -0
- package/src/lib/utils/stagent-paths.ts +12 -0
- package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
- package/src/lib/validators/__tests__/settings.test.ts +10 -0
- package/src/lib/validators/blueprint.ts +70 -9
- package/src/lib/validators/profile.ts +2 -2
- package/src/lib/validators/settings.ts +3 -1
- package/src/lib/workflows/__tests__/delay.test.ts +196 -0
- package/src/lib/workflows/__tests__/engine.test.ts +8 -0
- package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
- package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
- package/src/lib/workflows/blueprints/instantiator.ts +22 -1
- package/src/lib/workflows/blueprints/types.ts +10 -2
- package/src/lib/workflows/delay.ts +106 -0
- package/src/lib/workflows/engine.ts +207 -4
- package/src/lib/workflows/loop-executor.ts +349 -24
- package/src/lib/workflows/post-action.ts +91 -0
- package/src/lib/workflows/types.ts +166 -1
- package/src/app/api/license/checkout/route.ts +0 -28
- package/src/app/api/license/portal/route.ts +0 -26
- package/src/app/api/license/route.ts +0 -89
- package/src/app/api/license/usage/route.ts +0 -63
- package/src/app/api/marketplace/browse/route.ts +0 -15
- package/src/app/api/marketplace/import/route.ts +0 -28
- package/src/app/api/marketplace/publish/route.ts +0 -40
- package/src/app/api/onboarding/email/route.ts +0 -53
- package/src/app/api/settings/telemetry/route.ts +0 -14
- package/src/app/api/sync/export/route.ts +0 -54
- package/src/app/api/sync/restore/route.ts +0 -37
- package/src/app/api/sync/sessions/route.ts +0 -24
- package/src/app/auth/callback/route.ts +0 -73
- package/src/app/marketplace/page.tsx +0 -19
- package/src/components/analytics/analytics-gate-card.tsx +0 -101
- package/src/components/marketplace/blueprint-card.tsx +0 -61
- package/src/components/marketplace/marketplace-browser.tsx +0 -131
- package/src/components/onboarding/email-capture-card.tsx +0 -104
- package/src/components/settings/activation-form.tsx +0 -95
- package/src/components/settings/cloud-account-section.tsx +0 -147
- package/src/components/settings/cloud-sync-section.tsx +0 -155
- package/src/components/settings/subscription-section.tsx +0 -410
- package/src/components/settings/telemetry-section.tsx +0 -80
- package/src/components/shared/premium-gate-overlay.tsx +0 -50
- package/src/components/shared/schedule-gate-dialog.tsx +0 -64
- package/src/components/shared/upgrade-banner.tsx +0 -112
- package/src/hooks/use-supabase-auth.ts +0 -79
- package/src/lib/billing/email.ts +0 -54
- package/src/lib/billing/products.ts +0 -80
- package/src/lib/billing/stripe.ts +0 -101
- package/src/lib/cloud/supabase-browser.ts +0 -32
- package/src/lib/cloud/supabase-client.ts +0 -56
- package/src/lib/license/__tests__/features.test.ts +0 -56
- package/src/lib/license/__tests__/key-format.test.ts +0 -88
- package/src/lib/license/__tests__/manager.test.ts +0 -64
- package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
- package/src/lib/license/cloud-validation.ts +0 -60
- package/src/lib/license/features.ts +0 -44
- package/src/lib/license/key-format.ts +0 -101
- package/src/lib/license/limit-check.ts +0 -111
- package/src/lib/license/limit-queries.ts +0 -51
- package/src/lib/license/manager.ts +0 -345
- package/src/lib/license/notifications.ts +0 -59
- package/src/lib/license/tier-limits.ts +0 -71
- package/src/lib/marketplace/marketplace-client.ts +0 -107
- package/src/lib/sync/cloud-sync.ts +0 -235
- package/src/lib/telemetry/conversion-events.ts +0 -71
- package/src/lib/telemetry/queue.ts +0 -122
- package/src/lib/validators/license.ts +0 -33
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
resolvePostAction,
|
|
4
|
+
shouldSkipPostActionValue,
|
|
5
|
+
extractPostActionValue,
|
|
6
|
+
} from "../post-action";
|
|
7
|
+
import type { StepPostAction } from "../types";
|
|
8
|
+
|
|
9
|
+
describe("resolvePostAction", () => {
|
|
10
|
+
it("substitutes {{row.id}} with the row's id field", () => {
|
|
11
|
+
const action: StepPostAction = {
|
|
12
|
+
type: "update_row",
|
|
13
|
+
tableId: "tbl_contacts",
|
|
14
|
+
rowId: "{{row.id}}",
|
|
15
|
+
column: "linkedin",
|
|
16
|
+
};
|
|
17
|
+
const row = { id: "row_abc", name: "Alice" };
|
|
18
|
+
|
|
19
|
+
const resolved = resolvePostAction(action, row, "row");
|
|
20
|
+
|
|
21
|
+
expect(resolved.rowId).toBe("row_abc");
|
|
22
|
+
expect(resolved.tableId).toBe("tbl_contacts");
|
|
23
|
+
expect(resolved.column).toBe("linkedin");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("respects a custom itemVariable name", () => {
|
|
27
|
+
const action: StepPostAction = {
|
|
28
|
+
type: "update_row",
|
|
29
|
+
tableId: "tbl_x",
|
|
30
|
+
rowId: "{{contact.id}}",
|
|
31
|
+
column: "email",
|
|
32
|
+
};
|
|
33
|
+
const row = { id: "c_42" };
|
|
34
|
+
|
|
35
|
+
const resolved = resolvePostAction(action, row, "contact");
|
|
36
|
+
|
|
37
|
+
expect(resolved.rowId).toBe("c_42");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("supports nested field paths like {{row.meta.id}}", () => {
|
|
41
|
+
const action: StepPostAction = {
|
|
42
|
+
type: "update_row",
|
|
43
|
+
tableId: "tbl_x",
|
|
44
|
+
rowId: "{{row.meta.id}}",
|
|
45
|
+
column: "value",
|
|
46
|
+
};
|
|
47
|
+
const row = { meta: { id: "nested_99" } };
|
|
48
|
+
|
|
49
|
+
const resolved = resolvePostAction(action, row, "row");
|
|
50
|
+
|
|
51
|
+
expect(resolved.rowId).toBe("nested_99");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("leaves rowId untouched when no placeholder is present", () => {
|
|
55
|
+
const action: StepPostAction = {
|
|
56
|
+
type: "update_row",
|
|
57
|
+
tableId: "tbl_x",
|
|
58
|
+
rowId: "literal_row_id",
|
|
59
|
+
column: "value",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const resolved = resolvePostAction(action, { id: "ignored" }, "row");
|
|
63
|
+
|
|
64
|
+
expect(resolved.rowId).toBe("literal_row_id");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("shouldSkipPostActionValue", () => {
|
|
69
|
+
it("skips empty strings", () => {
|
|
70
|
+
expect(shouldSkipPostActionValue("")).toBe(true);
|
|
71
|
+
expect(shouldSkipPostActionValue(" ")).toBe(true);
|
|
72
|
+
expect(shouldSkipPostActionValue("\n\t")).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("skips NOT_FOUND sentinel (case-insensitive)", () => {
|
|
76
|
+
expect(shouldSkipPostActionValue("NOT_FOUND")).toBe(true);
|
|
77
|
+
expect(shouldSkipPostActionValue("not_found")).toBe(true);
|
|
78
|
+
expect(shouldSkipPostActionValue(" NOT_FOUND ")).toBe(true);
|
|
79
|
+
expect(shouldSkipPostActionValue("Not_Found")).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("does not skip real values", () => {
|
|
83
|
+
expect(shouldSkipPostActionValue("https://linkedin.com/in/alice")).toBe(false);
|
|
84
|
+
expect(shouldSkipPostActionValue("alice@example.com")).toBe(false);
|
|
85
|
+
expect(shouldSkipPostActionValue("0")).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does not skip values that merely contain NOT_FOUND as a substring", () => {
|
|
89
|
+
// We only skip when the trimmed value IS the sentinel; substrings stay.
|
|
90
|
+
expect(shouldSkipPostActionValue("Status: NOT_FOUND in registry")).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("extractPostActionValue", () => {
|
|
95
|
+
it("trims whitespace from the agent result", () => {
|
|
96
|
+
expect(extractPostActionValue(" hello ")).toBe("hello");
|
|
97
|
+
expect(extractPostActionValue("\nhttps://example.com\n")).toBe("https://example.com");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns the raw string for normal values", () => {
|
|
101
|
+
expect(extractPostActionValue("plain")).toBe("plain");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns empty string for null/undefined-shaped inputs", () => {
|
|
105
|
+
expect(extractPostActionValue(undefined)).toBe("");
|
|
106
|
+
expect(extractPostActionValue("")).toBe("");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -2,7 +2,7 @@ import { db } from "@/lib/db";
|
|
|
2
2
|
import { workflows } from "@/lib/db/schema";
|
|
3
3
|
import { getBlueprint } from "./registry";
|
|
4
4
|
import { resolveTemplate, evaluateCondition } from "./template";
|
|
5
|
-
import type { BlueprintVariable
|
|
5
|
+
import type { BlueprintVariable } from "./types";
|
|
6
6
|
import type { WorkflowStep } from "../types";
|
|
7
7
|
|
|
8
8
|
interface InstantiateResult {
|
|
@@ -48,6 +48,27 @@ export async function instantiateBlueprint(
|
|
|
48
48
|
continue;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Delay step: a pure time wait with no prompt/profile. Blueprint validation
|
|
52
|
+
// enforces that delayDuration and profileId+promptTemplate are mutually
|
|
53
|
+
// exclusive (XOR), so branching here is safe.
|
|
54
|
+
if (step.delayDuration) {
|
|
55
|
+
resolvedSteps.push({
|
|
56
|
+
id: crypto.randomUUID(),
|
|
57
|
+
name: step.name,
|
|
58
|
+
prompt: "",
|
|
59
|
+
requiresApproval: step.requiresApproval,
|
|
60
|
+
delayDuration: step.delayDuration,
|
|
61
|
+
});
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Task step: profileId + promptTemplate must be present (XOR contract).
|
|
66
|
+
if (!step.promptTemplate) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Blueprint step "${step.name}" has no promptTemplate — blueprint validation should have caught this.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
51
72
|
const resolvedPrompt = resolveTemplate(step.promptTemplate, resolvedVars);
|
|
52
73
|
|
|
53
74
|
resolvedSteps.push({
|
|
@@ -11,10 +11,18 @@ export interface BlueprintVariable {
|
|
|
11
11
|
max?: number;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* A blueprint step is either a task step (profileId + promptTemplate) OR a
|
|
16
|
+
* delay step (delayDuration only). The XOR is enforced at validation time
|
|
17
|
+
* by BlueprintStepSchema in src/lib/validators/blueprint.ts — at the type
|
|
18
|
+
* level, all three fields are optional so either shape is assignable.
|
|
19
|
+
*/
|
|
14
20
|
export interface BlueprintStep {
|
|
15
21
|
name: string;
|
|
16
|
-
profileId
|
|
17
|
-
promptTemplate
|
|
22
|
+
profileId?: string;
|
|
23
|
+
promptTemplate?: string;
|
|
24
|
+
/** If set, this step is a pure time delay. Format: Nm|Nh|Nd|Nw. */
|
|
25
|
+
delayDuration?: string;
|
|
18
26
|
requiresApproval: boolean;
|
|
19
27
|
expectedOutput?: string;
|
|
20
28
|
condition?: string;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duration parser and formatter for workflow step delays.
|
|
3
|
+
*
|
|
4
|
+
* Format: Nm (minutes), Nh (hours), Nd (days), Nw (weeks).
|
|
5
|
+
* Bounds: minimum 1 minute, maximum 30 days.
|
|
6
|
+
* Compound formats (e.g. "3d2h") are not supported.
|
|
7
|
+
*
|
|
8
|
+
* See features/workflow-step-delays.md for the spec.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MS_PER_MINUTE = 60_000;
|
|
12
|
+
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|
13
|
+
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
|
14
|
+
const MS_PER_WEEK = 7 * MS_PER_DAY;
|
|
15
|
+
|
|
16
|
+
const MIN_DURATION_MS = MS_PER_MINUTE;
|
|
17
|
+
const MAX_DURATION_MS = 30 * MS_PER_DAY;
|
|
18
|
+
|
|
19
|
+
const DURATION_PATTERN = /^(\d+)(m|h|d|w)$/;
|
|
20
|
+
|
|
21
|
+
const UNIT_MS: Record<string, number> = {
|
|
22
|
+
m: MS_PER_MINUTE,
|
|
23
|
+
h: MS_PER_HOUR,
|
|
24
|
+
d: MS_PER_DAY,
|
|
25
|
+
w: MS_PER_WEEK,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse a duration string into milliseconds.
|
|
30
|
+
*
|
|
31
|
+
* @throws if the format is invalid or the value is outside bounds.
|
|
32
|
+
*/
|
|
33
|
+
export function parseDuration(input: string): number {
|
|
34
|
+
const match = input.match(DURATION_PATTERN);
|
|
35
|
+
if (!match) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Invalid duration: "${input}". Use format: 30m, 2h, 3d, 1w`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const value = Number.parseInt(match[1], 10);
|
|
42
|
+
const unit = match[2];
|
|
43
|
+
const ms = value * UNIT_MS[unit];
|
|
44
|
+
|
|
45
|
+
if (ms < MIN_DURATION_MS) {
|
|
46
|
+
throw new Error(`Duration below minimum: "${input}". Minimum is 1 minute (1m).`);
|
|
47
|
+
}
|
|
48
|
+
if (ms > MAX_DURATION_MS) {
|
|
49
|
+
throw new Error(`Duration above maximum: "${input}". Maximum is 30 days (30d).`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return ms;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Result of checking whether a workflow step is a delay step or a task step.
|
|
57
|
+
* The engine branches on this — delay steps pause the workflow, task steps
|
|
58
|
+
* execute normally.
|
|
59
|
+
*/
|
|
60
|
+
export type DelayCheck =
|
|
61
|
+
| { type: "task" }
|
|
62
|
+
| { type: "delay"; resumeAt: number };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Classify a workflow step as either a delay step or a task step, and compute
|
|
66
|
+
* the resume timestamp for delay steps.
|
|
67
|
+
*
|
|
68
|
+
* Pure function: no I/O, no side effects. The engine calls this inside the
|
|
69
|
+
* sequence executor loop and branches on the result. Invalid duration formats
|
|
70
|
+
* throw — blueprint validation (src/lib/validators/blueprint.ts) should catch
|
|
71
|
+
* these at the workflow-creation boundary, so any invalid duration reaching
|
|
72
|
+
* here is a programming error and must fail loudly.
|
|
73
|
+
*
|
|
74
|
+
* @param step Workflow step (any object with an optional delayDuration field)
|
|
75
|
+
* @param now Current epoch timestamp in milliseconds (injected for testability)
|
|
76
|
+
*/
|
|
77
|
+
export function checkDelayStep(
|
|
78
|
+
step: { delayDuration?: string },
|
|
79
|
+
now: number,
|
|
80
|
+
): DelayCheck {
|
|
81
|
+
if (!step.delayDuration) {
|
|
82
|
+
return { type: "task" };
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
type: "delay",
|
|
86
|
+
resumeAt: now + parseDuration(step.delayDuration),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Format a millisecond duration back into the canonical string form.
|
|
92
|
+
* Prefers the largest unit that divides cleanly; falls back to minutes
|
|
93
|
+
* when no larger unit divides evenly.
|
|
94
|
+
*/
|
|
95
|
+
export function formatDuration(ms: number): string {
|
|
96
|
+
if (ms >= MS_PER_WEEK && ms % MS_PER_WEEK === 0) {
|
|
97
|
+
return `${ms / MS_PER_WEEK}w`;
|
|
98
|
+
}
|
|
99
|
+
if (ms >= MS_PER_DAY && ms % MS_PER_DAY === 0) {
|
|
100
|
+
return `${ms / MS_PER_DAY}d`;
|
|
101
|
+
}
|
|
102
|
+
if (ms >= MS_PER_HOUR && ms % MS_PER_HOUR === 0) {
|
|
103
|
+
return `${ms / MS_PER_HOUR}h`;
|
|
104
|
+
}
|
|
105
|
+
return `${ms / MS_PER_MINUTE}m`;
|
|
106
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { db } from "@/lib/db";
|
|
2
2
|
import { workflows, tasks, agentLogs, notifications } from "@/lib/db/schema";
|
|
3
|
-
import { eq } from "drizzle-orm";
|
|
3
|
+
import { and, eq } from "drizzle-orm";
|
|
4
4
|
import { executeTaskWithRuntime } from "@/lib/agents/runtime";
|
|
5
5
|
import { classifyTaskProfile } from "@/lib/agents/router";
|
|
6
6
|
import type { WorkflowDefinition, WorkflowState, StepState, LoopState } from "./types";
|
|
7
7
|
import { createInitialState } from "./types";
|
|
8
8
|
import { executeLoop } from "./loop-executor";
|
|
9
|
+
import { checkDelayStep } from "./delay";
|
|
9
10
|
import {
|
|
10
11
|
buildParallelSynthesisPrompt,
|
|
11
12
|
getParallelWorkflowStructure,
|
|
@@ -138,6 +139,24 @@ export async function executeWorkflow(workflowId: string): Promise<void> {
|
|
|
138
139
|
break;
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
// A delay step may have paused the workflow. The sequence executor already
|
|
143
|
+
// persisted the paused state and wrote resume_at; we just need to log the
|
|
144
|
+
// pause and return without marking the workflow "completed".
|
|
145
|
+
if (state.status === "paused") {
|
|
146
|
+
await db.insert(agentLogs).values({
|
|
147
|
+
id: crypto.randomUUID(),
|
|
148
|
+
taskId: null,
|
|
149
|
+
agentType: "workflow-engine",
|
|
150
|
+
event: "workflow_paused_for_delay",
|
|
151
|
+
payload: JSON.stringify({
|
|
152
|
+
workflowId,
|
|
153
|
+
delayedStepIndex: state.currentStepIndex,
|
|
154
|
+
}),
|
|
155
|
+
timestamp: new Date(),
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
141
160
|
state.status = "completed";
|
|
142
161
|
state.completedAt = new Date().toISOString();
|
|
143
162
|
await updateWorkflowState(workflowId, state, "completed");
|
|
@@ -179,20 +198,56 @@ export async function executeWorkflow(workflowId: string): Promise<void> {
|
|
|
179
198
|
|
|
180
199
|
/**
|
|
181
200
|
* Sequence pattern: execute steps one after another, passing output forward.
|
|
201
|
+
*
|
|
202
|
+
* @param fromStepIndex Start index for resuming after a delay. Defaults to 0
|
|
203
|
+
* for fresh executions. resumeWorkflow passes the index
|
|
204
|
+
* of the step after the one that was delayed.
|
|
182
205
|
*/
|
|
183
206
|
async function executeSequence(
|
|
184
207
|
workflowId: string,
|
|
185
208
|
definition: WorkflowDefinition,
|
|
186
209
|
state: WorkflowState,
|
|
187
210
|
parentTaskId?: string,
|
|
188
|
-
workflowRuntimeId?: string
|
|
211
|
+
workflowRuntimeId?: string,
|
|
212
|
+
fromStepIndex: number = 0,
|
|
189
213
|
): Promise<void> {
|
|
190
214
|
let previousOutput = "";
|
|
191
215
|
|
|
192
|
-
for (let i =
|
|
216
|
+
for (let i = fromStepIndex; i < definition.steps.length; i++) {
|
|
193
217
|
const step = definition.steps[i];
|
|
194
218
|
state.currentStepIndex = i;
|
|
195
219
|
|
|
220
|
+
// Delay step: pause the workflow and return. The scheduler tick will call
|
|
221
|
+
// resumeWorkflow when workflows.resume_at <= now(). See features/workflow-step-delays.md.
|
|
222
|
+
const delayCheck = checkDelayStep(step, Date.now());
|
|
223
|
+
if (delayCheck.type === "delay") {
|
|
224
|
+
state.stepStates[i].status = "delayed";
|
|
225
|
+
state.stepStates[i].startedAt = new Date().toISOString();
|
|
226
|
+
state.status = "paused";
|
|
227
|
+
await updateWorkflowState(workflowId, state, "paused");
|
|
228
|
+
// Write resume_at to the indexed workflows column so the scheduler tick
|
|
229
|
+
// can find this workflow efficiently via the partial index.
|
|
230
|
+
await db
|
|
231
|
+
.update(workflows)
|
|
232
|
+
.set({ resumeAt: delayCheck.resumeAt, updatedAt: new Date() })
|
|
233
|
+
.where(eq(workflows.id, workflowId));
|
|
234
|
+
await db.insert(agentLogs).values({
|
|
235
|
+
id: crypto.randomUUID(),
|
|
236
|
+
taskId: null,
|
|
237
|
+
agentType: "workflow-engine",
|
|
238
|
+
event: "step_delayed",
|
|
239
|
+
payload: JSON.stringify({
|
|
240
|
+
workflowId,
|
|
241
|
+
stepId: step.id,
|
|
242
|
+
stepName: step.name,
|
|
243
|
+
delayDuration: step.delayDuration,
|
|
244
|
+
resumeAt: delayCheck.resumeAt,
|
|
245
|
+
}),
|
|
246
|
+
timestamp: new Date(),
|
|
247
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
196
251
|
// Build prompt with context from previous step
|
|
197
252
|
const contextPrompt = previousOutput
|
|
198
253
|
? `Previous step output:\n${previousOutput}\n\n---\n\n${step.prompt}`
|
|
@@ -1101,7 +1156,20 @@ export async function updateWorkflowState(
|
|
|
1101
1156
|
|
|
1102
1157
|
if (!workflow) throw new Error(`Workflow ${workflowId} not found — cannot update state`);
|
|
1103
1158
|
|
|
1104
|
-
|
|
1159
|
+
// Defensive parse: a workflow row with a missing or corrupted definition
|
|
1160
|
+
// should not crash the engine. We still write the new _state on top, so a
|
|
1161
|
+
// recoverable run can continue even from a partially-written row.
|
|
1162
|
+
let definition: Record<string, unknown> = {};
|
|
1163
|
+
if (workflow.definition) {
|
|
1164
|
+
try {
|
|
1165
|
+
definition = JSON.parse(workflow.definition);
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
console.error(
|
|
1168
|
+
`[workflow-engine] Failed to parse definition for ${workflowId}, writing fresh state:`,
|
|
1169
|
+
err
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1105
1173
|
const combined = { ...definition, _state: state };
|
|
1106
1174
|
|
|
1107
1175
|
await db
|
|
@@ -1114,6 +1182,141 @@ export async function updateWorkflowState(
|
|
|
1114
1182
|
.where(eq(workflows.id, workflowId));
|
|
1115
1183
|
}
|
|
1116
1184
|
|
|
1185
|
+
/**
|
|
1186
|
+
* Resume a workflow that was paused at a delay step.
|
|
1187
|
+
*
|
|
1188
|
+
* Called by the scheduler tick when workflows.resume_at <= now(), and also
|
|
1189
|
+
* by the manual POST /api/workflows/[id]/resume endpoint when the user clicks
|
|
1190
|
+
* "Resume Now". The status transition is atomic (UPDATE ... WHERE status='paused')
|
|
1191
|
+
* so a scheduler tick and a user click racing each other produces exactly one
|
|
1192
|
+
* resume — the loser sees zero affected rows and returns silently.
|
|
1193
|
+
*
|
|
1194
|
+
* Only supports sequence-pattern workflows — other patterns never enter the
|
|
1195
|
+
* paused state in the first place (delay steps are sequence-only per spec).
|
|
1196
|
+
*/
|
|
1197
|
+
export async function resumeWorkflow(workflowId: string): Promise<void> {
|
|
1198
|
+
// Atomic status transition: only proceed if still paused.
|
|
1199
|
+
const updated = await db
|
|
1200
|
+
.update(workflows)
|
|
1201
|
+
.set({ status: "active", resumeAt: null, updatedAt: new Date() })
|
|
1202
|
+
.where(and(eq(workflows.id, workflowId), eq(workflows.status, "paused")))
|
|
1203
|
+
.returning();
|
|
1204
|
+
|
|
1205
|
+
if (updated.length === 0) {
|
|
1206
|
+
// Workflow is not paused — either already resumed (scheduler raced user,
|
|
1207
|
+
// or vice versa) or doesn't exist. Idempotent: no error, no action.
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const workflow = updated[0];
|
|
1212
|
+
const { definition, state } = parseWorkflowState(workflow.definition);
|
|
1213
|
+
|
|
1214
|
+
if (!state) {
|
|
1215
|
+
throw new Error(
|
|
1216
|
+
`Workflow ${workflowId} is marked paused but has no persisted state to resume`,
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (definition.pattern !== "sequence") {
|
|
1221
|
+
throw new Error(
|
|
1222
|
+
`Workflow ${workflowId} has pattern "${definition.pattern}" — resume is only supported for sequence pattern`,
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Mark the delayed step as completed, advance to the next step.
|
|
1227
|
+
const delayedIdx = state.currentStepIndex;
|
|
1228
|
+
const delayedStepState = state.stepStates[delayedIdx];
|
|
1229
|
+
if (delayedStepState && delayedStepState.status === "delayed") {
|
|
1230
|
+
delayedStepState.status = "completed";
|
|
1231
|
+
delayedStepState.completedAt = new Date().toISOString();
|
|
1232
|
+
}
|
|
1233
|
+
state.status = "running";
|
|
1234
|
+
const resumeFromIndex = delayedIdx + 1;
|
|
1235
|
+
|
|
1236
|
+
await db.insert(agentLogs).values({
|
|
1237
|
+
id: crypto.randomUUID(),
|
|
1238
|
+
taskId: null,
|
|
1239
|
+
agentType: "workflow-engine",
|
|
1240
|
+
event: "workflow_resumed",
|
|
1241
|
+
payload: JSON.stringify({ workflowId, resumeFromIndex }),
|
|
1242
|
+
timestamp: new Date(),
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
const parentTaskId = definition.sourceTaskId;
|
|
1246
|
+
const workflowRuntimeId = workflow.runtimeId ?? undefined;
|
|
1247
|
+
|
|
1248
|
+
// Reopen the learning session for this resume. Context proposals gathered
|
|
1249
|
+
// during the pre-pause run were already flushed when the original execute
|
|
1250
|
+
// closed its session; resume starts a fresh batch.
|
|
1251
|
+
openLearningSession(workflowId);
|
|
1252
|
+
|
|
1253
|
+
try {
|
|
1254
|
+
await executeSequence(
|
|
1255
|
+
workflowId,
|
|
1256
|
+
definition,
|
|
1257
|
+
state,
|
|
1258
|
+
parentTaskId,
|
|
1259
|
+
workflowRuntimeId,
|
|
1260
|
+
resumeFromIndex,
|
|
1261
|
+
);
|
|
1262
|
+
|
|
1263
|
+
// Another delay step may have been encountered during resume. TS narrows
|
|
1264
|
+
// state.status to "running" at this point because of the assignment above,
|
|
1265
|
+
// but executeSequence mutates state.status to "paused" when it hits a delay
|
|
1266
|
+
// step — the `as` cast forces TS to forget the narrowing and evaluate the
|
|
1267
|
+
// comparison at runtime.
|
|
1268
|
+
if ((state.status as WorkflowState["status"]) === "paused") {
|
|
1269
|
+
await db.insert(agentLogs).values({
|
|
1270
|
+
id: crypto.randomUUID(),
|
|
1271
|
+
taskId: null,
|
|
1272
|
+
agentType: "workflow-engine",
|
|
1273
|
+
event: "workflow_paused_for_delay",
|
|
1274
|
+
payload: JSON.stringify({
|
|
1275
|
+
workflowId,
|
|
1276
|
+
delayedStepIndex: state.currentStepIndex,
|
|
1277
|
+
}),
|
|
1278
|
+
timestamp: new Date(),
|
|
1279
|
+
});
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
state.status = "completed";
|
|
1284
|
+
state.completedAt = new Date().toISOString();
|
|
1285
|
+
await updateWorkflowState(workflowId, state, "completed");
|
|
1286
|
+
|
|
1287
|
+
await db.insert(agentLogs).values({
|
|
1288
|
+
id: crypto.randomUUID(),
|
|
1289
|
+
taskId: null,
|
|
1290
|
+
agentType: "workflow-engine",
|
|
1291
|
+
event: "workflow_completed",
|
|
1292
|
+
payload: JSON.stringify({ workflowId }),
|
|
1293
|
+
timestamp: new Date(),
|
|
1294
|
+
});
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
state.status = "failed";
|
|
1297
|
+
await updateWorkflowState(workflowId, state, "failed");
|
|
1298
|
+
|
|
1299
|
+
await db.insert(agentLogs).values({
|
|
1300
|
+
id: crypto.randomUUID(),
|
|
1301
|
+
taskId: null,
|
|
1302
|
+
agentType: "workflow-engine",
|
|
1303
|
+
event: "workflow_failed",
|
|
1304
|
+
payload: JSON.stringify({
|
|
1305
|
+
workflowId,
|
|
1306
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1307
|
+
}),
|
|
1308
|
+
timestamp: new Date(),
|
|
1309
|
+
});
|
|
1310
|
+
} finally {
|
|
1311
|
+
updateExecutionStats(workflowId).catch((err) => {
|
|
1312
|
+
console.error("[workflow-engine] Stats update failed:", err);
|
|
1313
|
+
});
|
|
1314
|
+
await closeLearningSession(workflowId).catch((err) => {
|
|
1315
|
+
console.error("[workflow-engine] Failed to close learning session:", err);
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1117
1320
|
/**
|
|
1118
1321
|
* Get the current state of a workflow.
|
|
1119
1322
|
*/
|