@keystrokehq/cli 0.1.24 → 0.1.25

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 (25) hide show
  1. package/dist/index.mjs +46 -52
  2. package/dist/index.mjs.map +1 -1
  3. package/dist/skills-bundle/_AGENTS.md +1 -1
  4. package/package.json +3 -3
  5. package/dist/skills-bundle/skills/keystroke-actions/SKILL.md +0 -160
  6. package/dist/skills-bundle/skills/keystroke-actions/references/catalog-and-imports.md +0 -71
  7. package/dist/skills-bundle/skills/keystroke-agents/SKILL.md +0 -115
  8. package/dist/skills-bundle/skills/keystroke-agents/references/models.md +0 -23
  9. package/dist/skills-bundle/skills/keystroke-agents/references/tools-mcp-codemode.md +0 -85
  10. package/dist/skills-bundle/skills/keystroke-agents/references/workflows-and-testing.md +0 -26
  11. package/dist/skills-bundle/skills/keystroke-apps/SKILL.md +0 -151
  12. package/dist/skills-bundle/skills/keystroke-apps/references/cli-and-catalog.md +0 -104
  13. package/dist/skills-bundle/skills/keystroke-channels/SKILL.md +0 -66
  14. package/dist/skills-bundle/skills/keystroke-channels/references/slack-setup.md +0 -41
  15. package/dist/skills-bundle/skills/keystroke-cli/SKILL.md +0 -93
  16. package/dist/skills-bundle/skills/keystroke-deploy/SKILL.md +0 -93
  17. package/dist/skills-bundle/skills/keystroke-deploy/references/build-and-full-deploy.md +0 -30
  18. package/dist/skills-bundle/skills/keystroke-deploy/references/filtered-deploy.md +0 -50
  19. package/dist/skills-bundle/skills/keystroke-deploy/references/wip-ignore.md +0 -35
  20. package/dist/skills-bundle/skills/keystroke-files/SKILL.md +0 -43
  21. package/dist/skills-bundle/skills/keystroke-skills/SKILL.md +0 -42
  22. package/dist/skills-bundle/skills/keystroke-triggers/SKILL.md +0 -143
  23. package/dist/skills-bundle/skills/keystroke-workflows/SKILL.md +0 -78
  24. package/dist/skills-bundle/skills/keystroke-workflows/references/authoring.md +0 -168
  25. package/dist/skills-bundle/skills/keystroke-workflows/references/testing.md +0 -138
@@ -1,143 +0,0 @@
1
- ---
2
- name: keystroke-triggers
3
- description: Wire keystroke workflows to cron, webhook, and poll sources in src/triggers/. Use when adding automation or auditing trigger runs.
4
- metadata:
5
- keystroke-domain: triggers
6
- ---
7
-
8
- # Triggers
9
-
10
- Triggers **attach** a source to a target (a workflow or an agent). No business logic here — only schedule, endpoint, validation, and filters.
11
-
12
- Attachment id: `{sourceSlug}:{targetSlug}` (e.g. `signup:signup-pipeline`), where the suffix is the workflow's (or agent's) `slug`.
13
-
14
- ## Cron
15
-
16
- ```ts
17
- import { defineCronSource } from "@keystrokehq/keystroke/trigger";
18
- import workflow from "../workflows/morning-check";
19
-
20
- export default defineCronSource({
21
- slug: "morning-check",
22
- schedule: "0 9 * * *",
23
- }).attach({ workflow });
24
- ```
25
-
26
- ## Webhook
27
-
28
- ```ts
29
- import { defineWebhookSource } from "@keystrokehq/keystroke/trigger";
30
- import { z } from "zod";
31
- import workflow from "../workflows/signup-pipeline";
32
-
33
- export default defineWebhookSource({
34
- slug: "signup",
35
- endpoint: "signup",
36
- request: z.object({
37
- name: z.string().trim().min(1),
38
- email: z.string().email(),
39
- company: z.string().trim().min(1),
40
- }),
41
- }).attach({ workflow });
42
- ```
43
-
44
- Use optional Zod `filter` for extra constraints beyond `request`:
45
-
46
- ```ts
47
- filter: z.object({ type: z.literal("invoice.paid") }),
48
- ```
49
-
50
- **Webhooks ack asynchronously.** The POST returns immediately — `202 { runId }` when a binding matches, or `{ ok: true, skipped: true }` when nothing does. The workflow runs in the background; its output is **not** returned in the HTTP response (there's no "respond to webhook"). To return data to the caller, make an outbound call from the workflow, or have the caller poll the run via the runs API / `keystroke workflow runs get`.
51
-
52
- ### Shared endpoint (e.g. Stripe)
53
-
54
- Multiple trigger files can use the same `endpoint` — one URL `POST /triggers/{endpoint}`, each with its own `slug`, `request`, `filter`, and `transform`. Unmatched payloads return `{ ok: true, skipped: true }`.
55
-
56
- ```ts
57
- // src/triggers/stripe-invoice-paid.ts
58
- export default defineWebhookSource({
59
- slug: "stripe-invoice-paid",
60
- endpoint: "stripe",
61
- request: z.object({ type: z.string(), data: z.object({ id: z.string() }) }),
62
- filter: z.object({ type: z.literal("invoice.paid") }),
63
- }).attach({ workflow: invoicePaidWorkflow, transform: (p) => ({ invoiceId: p.data.id }) });
64
-
65
- // src/triggers/stripe-subscription-deleted.ts — same endpoint, different slug/schema/filter
66
- ```
67
-
68
- List or inspect all triggers on an endpoint:
69
-
70
- ```bash
71
- keystroke trigger list --endpoint stripe
72
- keystroke trigger get stripe # same rows as list --endpoint (shared route)
73
- keystroke trigger url stripe # one webhook URL for the route
74
- ```
75
-
76
- Use each trigger's `slug` for `trigger get` / run history (`keystroke trigger runs list stripe-invoice-paid:…`).
77
-
78
- ## Poll
79
-
80
- ```ts
81
- import { definePollSource } from "@keystrokehq/keystroke/trigger";
82
- import workflow from "../workflows/new-inbox";
83
-
84
- export default definePollSource({
85
- slug: "new-inbox",
86
- schedule: "*/5 * * * *",
87
- run: () => ({ emails: [] }),
88
- }).attach({ workflow });
89
- ```
90
-
91
- Poll filtering uses a **function predicate** (not a Zod schema like webhooks) — either `.filter((result) => …)` chained on the source, or a `filter:` option. Returning falsy skips the tick. Group polls that should run together with `definePollSource({ id: "...", … })`.
92
-
93
- ### Ephemeral poll (agents)
94
-
95
- Agents can register a scheduled codemode script with `set_trigger`:
96
-
97
- ```ts
98
- set_trigger({
99
- kind: "poll",
100
- slug: "inbox",
101
- schedule: "*/5 * * * *",
102
- code: [
103
- 'const emails = await tools["list-emails"]({ query: "is:unread" });',
104
- "if (emails.items.length === 0) return;",
105
- "console.log(JSON.stringify({ count: emails.items.length, items: emails.items }));",
106
- ].join("\n"),
107
- prompt: "You have {{payload.count}} unread emails.",
108
- lifecycle: { maxExecutions: 10 }, // optional: cap runs (also `until`)
109
- });
110
- ```
111
-
112
- - Write the script the same way you would for codemode (`bash` + `js-exec`).
113
- - `console.log(JSON.stringify(result))` when there is work to do.
114
- - Log nothing (or `null`) to skip — skipped ticks do not count toward `lifecycle.maxExecutions`.
115
- - Prompt interpolation matches webhooks (`{{payload.path}}`).
116
-
117
- ## Attachment patterns
118
-
119
- - **Attach to an agent** instead of a workflow: `.attach({ agent, prompt })` — the source's payload drives the agent prompt (interpolated like webhooks).
120
- - **Fan-out to multiple targets**: chain `.attach(...).attach(...)` (or export an array of attachments) to bind one source to several workflows/agents.
121
- - **Shared source definitions** can live under `src/triggers/sources/` — that subfolder is excluded from attachment discovery, so it's a safe place for source defs you import elsewhere.
122
- - Sources also accept optional `name` / `description` metadata.
123
-
124
- ## Develop & audit
125
-
126
- While building, invoke the workflow directly:
127
-
128
- ```bash
129
- keystroke workflow run signup-pipeline --input '{"name":"Ada","email":"a@acme.com","company":"Acme"}'
130
- ```
131
-
132
- Inspect trigger-driven runs:
133
-
134
- ```bash
135
- keystroke trigger runs list signup:signup-pipeline # --limit / --cursor / --trigger-type
136
- keystroke trigger runs get signup:signup-pipeline <run-id> --include workflows,trace
137
- keystroke trigger poll <poll-attachment-id> # on-demand poll (--group to run a poll group)
138
- keystroke trigger attachment disable <trigger-slug> <attachment-id> # pause; `enable` to resume
139
- ```
140
-
141
- Check `src/triggers/` in your project for existing patterns before adding new ones.
142
-
143
- Related: [workflows](.agents/skills/keystroke-workflows/SKILL.md), [channels](.agents/skills/keystroke-channels/SKILL.md).
@@ -1,78 +0,0 @@
1
- ---
2
- name: keystroke-workflows
3
- description: Build keystroke workflows with defineWorkflow — chain actions, call agents, typed Zod IO. Use when authoring src/workflows/ or auditing workflow runs.
4
- metadata:
5
- keystroke-domain: workflows
6
- ---
7
-
8
- # Workflows
9
-
10
- Workflows are **deterministic orchestration**: Zod input/output, a `run` function, and **actions as steps**. Triggers and the CLI invoke them; agents can participate inside actions.
11
-
12
- ## Example: action chain
13
-
14
- ```ts
15
- import { defineWorkflow } from "@keystrokehq/keystroke/workflow";
16
- import { slackSendMessage } from "@keystrokehq/slack/actions";
17
- import { z } from "zod";
18
- import { researchSignup } from "../actions/research-signup";
19
- import { signupBriefMessage } from "../lib/signup";
20
-
21
- export default defineWorkflow({
22
- slug: "signup-pipeline",
23
- input: z.object({ name: z.string(), email: z.string().email(), company: z.string() }),
24
- output: z.object({ brief: z.string(), channel: z.string(), ts: z.string() }),
25
- async run(input) {
26
- const { brief } = await researchSignup.run(input);
27
- const sent = await slackSendMessage.run({
28
- channel: "#pipeline",
29
- markdown_text: signupBriefMessage({ ...input, brief }),
30
- });
31
- return { brief, ...sent }; // sent provides { channel, ts }; merge in brief for the output schema
32
- },
33
- });
34
- ```
35
-
36
- `research-signup` calls an agent; `slackSendMessage` is an integration action used **directly as a step** here — never wrapped in a custom action. Note the `run` return is spread to satisfy the `output` schema (the Slack action only returns `channel`/`ts`). Keep orchestration in the workflow; an action is a single leaf step and never calls another action.
37
-
38
- ## Run & audit
39
-
40
- ```bash
41
- keystroke workflow run signup-pipeline --input '{"name":"Ada","email":"ada@acme.com","company":"Acme"}'
42
- keystroke workflow runs list signup-pipeline
43
- keystroke workflow runs get signup-pipeline <run-id> --include steps,trace
44
- ```
45
-
46
- ## How workflows get invoked
47
-
48
- | From | How |
49
- | ---------- | --------------------------------------------------------------------- |
50
- | CLI | `keystroke workflow run {slug} --input '{...}'` |
51
- | HTTP | `POST /workflows/{slug}` |
52
- | Trigger | cron / webhook / poll attachment in `src/triggers/` |
53
- | Agent tool | import the workflow into an agent's `tools: [workflow]` |
54
- | Sub-workflow step | import the workflow and `await otherWorkflow.run(input)` inside another workflow's `run` body |
55
-
56
- To reuse a workflow inside another workflow, import it and call `await otherWorkflow.run(input)` — a first-class durable step, just like an action or agent (validates the sub-workflow's IO, checkpoints its output, gets its own trace span). The sub-workflow runs inline in the same run, so its inner steps and `ctx.sleep`/`ctx.hook` stay durable; it does **not** spawn a separate tracked run. Never thread `ctx` into it (`otherWorkflow.run(input, ctx)` is not a thing). To run a workflow as its *own* tracked run, import it into an agent's `tools` or invoke it over HTTP.
57
-
58
- The workflow `slug` is its identifier and route key. Slugs are unique per primitive kind, so a workflow can share a slug with an agent or trigger, but two workflows cannot share one.
59
-
60
- ## Durability (the model you must design for)
61
-
62
- Workflows are **durable**: each `await` of an action, agent `.prompt()`, or `promptLlm` is recorded as a `step_completed` event. If a later step fails, the run **replays** the log — completed steps return their recorded result instead of running again — and resumes at the first unfinished step. Two rules follow:
63
-
64
- - **Side effects go inside steps.** Code in the `run` body that isn't a step (network calls, writes, `Date.now()`, random) re-executes on every replay. Wrap it in an action/agent/`promptLlm` call so it's recorded once.
65
- - **Keep control flow deterministic, steps idempotent.** Branch on input and recorded step results, not on values that change between attempts. A step can be retried after a transient failure, so design actions to tolerate being called twice with the same input. Retries are automatic — you don't write retry loops; the runtime emits `step_retrying` / `step_failed`.
66
-
67
- Step ids are correlation ids: `step:<slug>#<occurrence>` (`#0`, `#1`, …), or `step:<id>` when pinned with `.stepId()`. If an action's position can shift, pin a stable `.stepId()` so replays line up. Durable waits (`ctx.sleep`, `ctx.hook`) suspend the run without holding a process open. See [authoring.md](references/authoring.md).
68
-
69
- ## Testing
70
-
71
- Unit-test through `executeWorkflow` from `@keystrokehq/keystroke/workflow` — never call `workflow.run(...)` directly at the top level of a test (outside a run it has no durable context). Stub costly actions by seeding `step_completed` events in a `MemoryEventLog`. See [testing.md](references/testing.md).
72
-
73
- ## Next references
74
-
75
- - [authoring.md](references/authoring.md) — agents in actions, app connections, composition
76
- - [testing.md](references/testing.md) — unit tests, stubbing actions, run/trace debugging
77
-
78
- Related: [actions](.agents/skills/keystroke-actions/SKILL.md), [triggers](.agents/skills/keystroke-triggers/SKILL.md), [agents](.agents/skills/keystroke-agents/SKILL.md).
@@ -1,168 +0,0 @@
1
- # Workflow authoring
2
-
3
- ## Keep steps in actions (when they are not agent prompts)
4
-
5
- Workflow `run` orchestrates — call actions for deterministic logic and integrations, call agents directly for LLM steps:
6
-
7
- ```ts
8
- async run(input) {
9
- const { brief } = await researchSignup.run(input);
10
- const summary = await support.prompt({
11
- message: `Summarize signup research:\n${brief}`,
12
- });
13
- const sent = await slackSendMessage.run({
14
- channel: "#pipeline",
15
- markdown_text: signupBriefMessage({ ...input, brief }),
16
- });
17
- return { brief, ...sent };
18
- }
19
- ```
20
-
21
- Actions are leaf units: an action never calls another action — compose them in the workflow. Need formatting or shared logic between steps? Put it in `src/lib/`, not in a wrapper action.
22
-
23
- ## Agents as workflow steps
24
-
25
- Import the agent and call `.prompt()` directly in the workflow `run`. Each call is a durable step keyed as `step:<agent-slug>#<occurrence>` (same scheme as actions). No wrapper action required when the step is just "prompt this agent."
26
-
27
- ```ts
28
- const result = await support.prompt({
29
- message: `From: ${input.sender}\nSubject: ${input.latestSubject}`,
30
- });
31
- ```
32
-
33
- Use a wrapper action only when the agent call should also be an agent tool, or when the step bundles non-prompt logic.
34
-
35
- For an explicit step id, actions chain `.stepId("x")`; agent prompts take it as an option: `agent.prompt(input, { stepId: "x" })`.
36
-
37
- ## LLM steps (`promptLlm`)
38
-
39
- For a one-shot LLM call without a full agent, use `promptLlm` (from `@keystrokehq/keystroke/workflow`). It's a first-class durable step keyed `step:promptLlm#<occurrence>`. The signature is `promptLlm(prompt, opts)` — the prompt string comes **first**, options second:
40
-
41
- ```ts
42
- import { promptLlm } from "@keystrokehq/keystroke/workflow";
43
-
44
- // Returns a string when no outputSchema is given:
45
- const summary = await promptLlm(`Summarize:\n${brief}`, {
46
- model: "anthropic/claude-sonnet-4.6",
47
- });
48
- ```
49
-
50
- Pass `outputSchema` (Zod) to get a parsed, schema-validated object back instead of a string — the return type is inferred from the schema:
51
-
52
- ```ts
53
- import { z } from "zod";
54
-
55
- const Category = z.object({ category: z.enum(["bug", "feature", "question"]) });
56
-
57
- const { category } = await promptLlm(`Classify this ticket:\n${ticket}`, {
58
- model: "anthropic/claude-sonnet-4.6",
59
- outputSchema: Category,
60
- });
61
- ```
62
-
63
- Other options: `system`, `thinkingLevel`, `temperature`, `maxTokens`, `stepId`.
64
-
65
- ## Sub-workflows as steps
66
-
67
- Reuse a whole workflow by importing it and awaiting `.run(input)` — a first-class durable step, same as an action. Keystroke validates the sub-workflow's `input`/`output`, records its output as a checkpoint keyed `step:<sub-slug>#<occurrence>`, and gives it a trace span.
68
-
69
- ```ts
70
- import enrichLead from "./enrich-lead";
71
-
72
- async run(input) {
73
- const enriched = await enrichLead.run({ email: input.email });
74
- return { score: enriched.score };
75
- }
76
- ```
77
-
78
- The sub-workflow runs **inline** in the same run: its inner action/agent/`promptLlm` steps and durable waits (`ctx.sleep` / `ctx.hook`) all stay durable, and it does **not** spawn a separate tracked run. Never thread `ctx` — `enrichLead.run(input, ctx)` is not the pattern; the engine supplies the context. Pin an explicit id with `.stepId("x")` when the call's position can shift. For an *independently tracked* run instead, import the workflow into an agent's `tools` or call it over HTTP.
79
-
80
- ## Durability & retries
81
-
82
- As each step completes, its result is written to a run event log. If a later step fails and the run is retried, Keystroke replays the log: completed steps return their recorded result instead of running again, and execution resumes at the first unfinished step. Design for this:
83
-
84
- - **Put side effects inside steps.** Work in the `run` body that isn't an action / agent / `promptLlm` call (a raw `fetch`, a DB write, `Date.now()`, `Math.random()`) runs again on every replay. Move it into a step so it's recorded once.
85
- - **Steps should be idempotent.** A step can be retried after a transient failure, so an action should tolerate being called twice with the same input (use idempotency keys for external writes where it matters).
86
- - **Keep control flow deterministic.** Branch on the run's input and recorded step results — not on wall-clock time or randomness — so replays take the same path. Each step is recorded under `step:<slug>#<occurrence>`; pin `.stepId("x")` when an action's order can shift so replays still line up. Ids must be unique within a run.
87
-
88
- You don't manage retries yourself. The runtime records `step_completed`, `step_retrying`, and `step_failed` events and surfaces them in `keystroke workflow runs get <slug> <run-id> --include steps,trace`.
89
-
90
- ## Parallel steps
91
-
92
- Independent steps run concurrently with `Promise.all` — each `.run()` / `.prompt()` / `promptLlm` inside it is still its own durable step, recorded and replayed individually:
93
-
94
- ```ts
95
- const [enriched, scored] = await Promise.all([
96
- enrichLead.run({ leadId }),
97
- scoreLead.run({ leadId }),
98
- ]);
99
- ```
100
-
101
- Correlation ids are assigned per slug in array order (`step:enrich-lead#0`, `step:score-lead#0`). If the branches can change order between attempts (conditionally included, dynamic arrays), pin a stable `.stepId()` on each so replays line up:
102
-
103
- ```ts
104
- const results = await Promise.all(
105
- leads.map((lead) => enrichLead.run({ leadId: lead.id }).stepId(`enrich:${lead.id}`)),
106
- );
107
- ```
108
-
109
- There's no built-in concurrency limit or fan-out helper — `Promise.all` runs everything at once. For bounded concurrency, batch the array yourself (e.g. chunk and `await` each chunk).
110
-
111
- ## Handling failures
112
-
113
- Retries are **queue-level**: a failed run is re-enqueued (default 3 attempts, exponential backoff) and replays from the event log, so completed steps don't re-run. Each failed attempt emits `step_retrying`; the final failure emits `step_failed`. You don't write retry loops — make steps idempotent so a retried step is safe.
114
-
115
- There's no built-in saga, compensation engine, or dead-letter queue. When a later step fails and you need to undo earlier work, orchestrate it yourself with `try/catch` in `run` — catching the error lets the run complete normally (so attach a `compensated` flag to the output rather than rethrowing if you've handled it):
116
-
117
- ```ts
118
- async run(input) {
119
- await reserveBooking.run({ id: input.bookingId });
120
- try {
121
- await chargeCustomer.run({ id: input.bookingId });
122
- return { ok: true };
123
- } catch (error) {
124
- await releaseBooking.run({ id: input.bookingId }); // best-effort cleanup
125
- return { ok: false, compensated: true };
126
- }
127
- }
128
- ```
129
-
130
- If you let the error propagate instead, the run fails and the queue retries it — fine for transient failures, but it replays from the log, so any cleanup must itself be a recorded, idempotent step.
131
-
132
- ## Durable run context (`ctx`)
133
-
134
- `run(input, ctx)` receives a second argument exposing `runId`, `trigger` (`api` | `cron` | `webhook` | `poll` | `retry`), and the durable wait primitives `ctx.sleep(...)` and `ctx.hook(...)`. Both **suspend** the run (the replay engine resumes it later) so long waits and external callbacks don't hold a process open.
135
-
136
- ```ts
137
- // Durable delay: number (ms), duration string, or a Date
138
- await ctx.sleep("1h");
139
-
140
- // Durable hook: suspend until an external system POSTs to the resume URL
141
- async run(input, ctx) {
142
- const approval = ctx.hook<{ approved: boolean }>();
143
- await slackSendMessage.run({
144
- channel: "#approvals",
145
- markdown_text: `Approve deploy? ${approval.resumeUrl}`,
146
- });
147
- const { approved } = await approval; // suspends here until resumed
148
- return { approved };
149
- }
150
- ```
151
-
152
- `ctx.hook(options?)` takes an optional `token` (reuse a stable resume token) and `schema` (Zod validation of the resume payload). The returned handle exposes `token` and `resumeUrl` before you await it — surface that URL so the approver/callback can resume the run.
153
-
154
- ## Agents inside actions
155
-
156
- When an action must call an agent (e.g. the action is reused as an agent tool), call `.prompt()` inside the action's `run`. That prompt is not a separate workflow step — the action is.
157
-
158
- ## Connecting apps
159
-
160
- Sync the app into `src/apps/`, author with `app.action()`, then `keystroke connect <slug>` before testing. Catalog integration actions from npm packages work once the app is connected.
161
-
162
- ## Slugs
163
-
164
- Slugs are scoped per primitive kind: a `slug` must be unique among workflows, among agents, and among triggers, but the same `slug` may be reused across kinds (a `pricing` agent and a `pricing` workflow can coexist). The workflow's `slug` is also its HTTP route segment (`POST /workflows/{slug}`).
165
-
166
- ## Subscription mode
167
-
168
- `defineWorkflow` accepts an optional `subscription: { mode: "system" | "subscribable" }` to control how the workflow can be subscribed to. Omit it for the default behavior.
@@ -1,138 +0,0 @@
1
- # Testing & debugging workflows
2
-
3
- ## Unit tests (in-process, no server)
4
-
5
- Test through `executeWorkflow` from `@keystrokehq/keystroke/workflow` — it parses input/output and wires up the action/sub-workflow runners. **Never call `workflow.run(...)` directly at the top level of a test**: outside a run it has no durable context (`workflow.run(input)` returns an unresolvable step; `workflow.run(input, ctx)` skips the checkpoint boundary). Inside another workflow body, `await otherWorkflow.run(input)` *is* the correct first-class sub-workflow step.
6
-
7
- `executeWorkflow` runs the workflow via the durable replay engine and resolves to a `ReplayResult`: `{ status: "completed", output }`, `{ status: "failed", error }`, `{ status: "suspended", items }` (when the body hits `ctx.sleep`/`ctx.hook`), or `{ status: "canceled" }`. Assert on `status` and read `output`.
8
-
9
- `keystroke init` scaffolds Vitest and a starter test (e.g. `src/workflows/greeting.test.ts`):
10
-
11
- ```ts
12
- import { describe, expect, it } from "vitest";
13
- import { executeWorkflow } from "@keystrokehq/keystroke/workflow";
14
- import greeting from "./greeting";
15
-
16
- describe("greeting workflow", () => {
17
- it("returns a greeting for a name", async () => {
18
- const result = await executeWorkflow(greeting, { name: "Ada" });
19
- expect(result).toEqual({ status: "completed", output: { greeting: "Hello, Ada!" } });
20
- });
21
- });
22
- ```
23
-
24
- Use `pnpm test:unit` for `src/**/*.test.ts`. Integration tests (`*.int.test.ts`) load `.env` and skip when required keys are unset (`pnpm test:int`).
25
-
26
- For multi-step workflows:
27
-
28
- ```ts
29
- import { describe, expect, it } from "vitest";
30
- import { executeWorkflow } from "@keystrokehq/keystroke/workflow";
31
- import workflow from "../signup-pipeline";
32
-
33
- it("runs the pipeline", async () => {
34
- const result = await executeWorkflow(workflow, {
35
- name: "Ada",
36
- email: "ada@acme.com",
37
- company: "Acme",
38
- });
39
-
40
- expect(result.status).toBe("completed");
41
- if (result.status === "completed") {
42
- expect(result.output).toMatchObject({ channel: "#pipeline" });
43
- }
44
- });
45
- ```
46
-
47
- Deterministic actions (pure logic, no network/LLM) need no mocks — run the workflow and assert.
48
-
49
- ## Stub expensive actions by seeding the event log
50
-
51
- Steps are checkpointed in the durable event log as `step_completed` events keyed by a stable **correlation id**. Before running a step the action runner checks the log for that correlation id; pre-seed a `step_completed` event to skip real work (LLM/agent/HTTP calls) and feed a fixture:
52
-
53
- ```ts
54
- import { executeWorkflow, MemoryEventLog } from "@keystrokehq/keystroke/workflow";
55
- import workflow from "../signup-pipeline";
56
-
57
- it("drafts without calling the agent", async () => {
58
- const eventLog = new MemoryEventLog();
59
- const runId = "test-run";
60
-
61
- // correlation id = `step:<actionKey>#<occurrence>` (occurrence starts at 0),
62
- // or `step:<stepId>` if the step used `.stepId("...")`.
63
- await eventLog.append({
64
- runId,
65
- type: "step_completed",
66
- correlationId: "step:research-signup#0",
67
- data: { brief: "stubbed brief" }, // the action output goes in `data` directly
68
- });
69
-
70
- const result = await executeWorkflow(
71
- workflow,
72
- { name: "Ada", email: "ada@acme.com", company: "Acme" },
73
- { runId, eventLog },
74
- );
75
-
76
- expect(result.status).toBe("completed");
77
- if (result.status === "completed") {
78
- expect(result.output).toMatchObject({ brief: "stubbed brief" });
79
- }
80
- });
81
- ```
82
-
83
- Rules:
84
-
85
- - Pass a fixed `runId` and the same `MemoryEventLog` to `executeWorkflow`.
86
- - The correlation id is `step:<slug>#<occurrence>` — `#0` for the first call to that action/agent, `#1` for the second, and so on — unless an explicit step id was set (`action.stepId("x")` or `agent.prompt(input, { stepId: "x" })`), in which case it is `step:x`.
87
- - The action **output** is stored as the event `data` directly (no wrapper). It is re-validated against the action's output schema, so it must be schema-valid.
88
-
89
- This is the preferred mock: schema-checked, no module-mock hoisting, and it still exercises the real `run` orchestration (branching, loops, output shape) — only the action bodies are stubbed.
90
-
91
- ## Mock the integration instead (alternative)
92
-
93
- When you'd rather stub the underlying dependency (e.g. the agent inside an action) or assert how it was called, use `vi.mock`:
94
-
95
- `agent.prompt()` resolves to a `PromptResponse` — `{ sessionId, messages, error, canceled?, output? }` (`output` is the parsed result when the prompt was called with an `outputSchema`), **not** a bare messages array. Mock that shape:
96
-
97
- ```ts
98
- import { vi } from "vitest";
99
-
100
- vi.mock("../agents/support", () => ({
101
- default: {
102
- prompt: vi.fn().mockResolvedValue({
103
- sessionId: "test-session",
104
- messages: [{ role: "assistant", content: "mocked" }],
105
- error: null,
106
- }),
107
- },
108
- }));
109
- ```
110
-
111
- ## Assert the input/output contract
112
-
113
- Validation happens at the `executeWorkflow` boundary, so contract tests are free:
114
-
115
- ```ts
116
- await expect(executeWorkflow(workflow, { email: "x" } as never)).rejects.toThrow();
117
- ```
118
-
119
- ## Debug runs from the CLI
120
-
121
- ```bash
122
- keystroke workflow run signup-pipeline --input '{"name":"Ada","email":"ada@acme.com","company":"Acme"}'
123
- keystroke workflow runs list signup-pipeline
124
- keystroke workflow runs get signup-pipeline <run-id> --include steps,trace
125
- ```
126
-
127
- `steps` shows which actions ran and failed. `trace` links nested agent calls.
128
-
129
- ### Trigger-fired runs
130
-
131
- When a cron/webhook/poll fired the workflow:
132
-
133
- ```bash
134
- keystroke trigger runs list signup:signup-pipeline
135
- keystroke trigger runs get signup:signup-pipeline <run-id> --include workflows,trace
136
- ```
137
-
138
- While developing triggers, prefer `keystroke workflow run` until the workflow logic is solid.