@pikku/core 0.12.69 → 0.12.70
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/CHANGELOG.md +334 -0
- package/README.md +34 -2
- package/dist/function/functions.types.d.ts +27 -0
- package/dist/index.d.ts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.js +1 -1
- package/dist/pikku-state.js +1 -0
- package/dist/services/http-scenario-actors.d.ts +12 -4
- package/dist/services/http-scenario-actors.js +47 -45
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.js +1 -0
- package/dist/services/meta-service.d.ts +5 -1
- package/dist/services/meta-service.js +44 -18
- package/dist/services/scenario-actors-service.d.ts +108 -2
- package/dist/services/scenario-actors-service.js +40 -1
- package/dist/types/core.types.d.ts +21 -3
- package/dist/types/state.types.d.ts +3 -1
- package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
- package/dist/wirings/actor-flow/index.d.ts +1 -1
- package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
- package/dist/wirings/actor-flow/run-conversation.js +27 -27
- package/dist/wirings/cli/command-parser.js +11 -1
- package/dist/wirings/rpc/rpc-runner.js +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
- package/dist/wirings/workflow/feature.d.ts +28 -0
- package/dist/wirings/workflow/feature.js +57 -0
- package/dist/wirings/workflow/index.d.ts +13 -2
- package/dist/wirings/workflow/index.js +15 -0
- package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
- package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +103 -10
- package/dist/wirings/workflow/pikku-workflow-service.js +80 -135
- package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
- package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
- package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
- package/dist/wirings/workflow/scenario-poll.js +25 -0
- package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
- package/dist/wirings/workflow/scenario-prose.js +45 -0
- package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
- package/dist/wirings/workflow/scenario-step-guards.js +29 -0
- package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
- package/dist/wirings/workflow/scenario-step.types.js +1 -0
- package/dist/wirings/workflow/workflow.types.d.ts +81 -2
- package/package.json +3 -1
- package/src/function/functions.types.ts +32 -0
- package/src/index.ts +1 -0
- package/src/internal.ts +5 -1
- package/src/pikku-state.ts +1 -0
- package/src/services/http-scenario-actors.test.ts +85 -1
- package/src/services/http-scenario-actors.ts +65 -51
- package/src/services/index.ts +5 -0
- package/src/services/meta-service.test.ts +79 -0
- package/src/services/meta-service.ts +61 -26
- package/src/services/scenario-actors-service.ts +157 -2
- package/src/types/core.types.ts +27 -2
- package/src/types/state.types.ts +3 -0
- package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
- package/src/wirings/actor-flow/index.ts +1 -1
- package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
- package/src/wirings/actor-flow/run-conversation.ts +36 -41
- package/src/wirings/cli/command-parser.test.ts +60 -0
- package/src/wirings/cli/command-parser.ts +12 -1
- package/src/wirings/rpc/rpc-runner.test.ts +28 -5
- package/src/wirings/rpc/rpc-runner.ts +1 -1
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
- package/src/wirings/workflow/feature.test.ts +131 -0
- package/src/wirings/workflow/feature.ts +78 -0
- package/src/wirings/workflow/index.ts +73 -0
- package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +55 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +196 -208
- package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
- package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
- package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
- package/src/wirings/workflow/scenario-poll.test.ts +66 -0
- package/src/wirings/workflow/scenario-poll.ts +36 -0
- package/src/wirings/workflow/scenario-prose.test.ts +152 -0
- package/src/wirings/workflow/scenario-prose.ts +79 -0
- package/src/wirings/workflow/scenario-service.test.ts +155 -0
- package/src/wirings/workflow/scenario-step-guards.ts +43 -0
- package/src/wirings/workflow/scenario-step.test.ts +441 -8
- package/src/wirings/workflow/scenario-step.types.ts +157 -0
- package/src/wirings/workflow/workflow.types.ts +98 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -11,8 +11,8 @@ import type {
|
|
|
11
11
|
AIAgentStepResult,
|
|
12
12
|
} from '../../services/ai-agent-runner-service.js'
|
|
13
13
|
|
|
14
|
-
/** One turn the
|
|
15
|
-
const
|
|
14
|
+
/** One turn the actor takes: the message to send and whether it's finished. */
|
|
15
|
+
const ACTOR_TURN_SCHEMA = {
|
|
16
16
|
type: 'object',
|
|
17
17
|
properties: {
|
|
18
18
|
message: { type: 'string' },
|
|
@@ -21,7 +21,7 @@ const PERSONA_TURN_SCHEMA = {
|
|
|
21
21
|
required: ['message', 'done'],
|
|
22
22
|
} as const
|
|
23
23
|
|
|
24
|
-
/** The
|
|
24
|
+
/** The actor's approve/deny decision for each pending tool request. */
|
|
25
25
|
const APPROVAL_DECISION_SCHEMA = {
|
|
26
26
|
type: 'object',
|
|
27
27
|
properties: {
|
|
@@ -41,7 +41,7 @@ const APPROVAL_DECISION_SCHEMA = {
|
|
|
41
41
|
required: ['decisions'],
|
|
42
42
|
} as const
|
|
43
43
|
|
|
44
|
-
/** The
|
|
44
|
+
/** The actor's final verdict on whether the task was accomplished. */
|
|
45
45
|
const EVALUATION_SCHEMA = {
|
|
46
46
|
type: 'object',
|
|
47
47
|
properties: {
|
|
@@ -55,23 +55,23 @@ const DEFAULT_MAX_TURNS = 12
|
|
|
55
55
|
|
|
56
56
|
const DEFAULT_MAX_APPROVAL_ROUNDS = 16
|
|
57
57
|
|
|
58
|
-
/** The LLM call the
|
|
59
|
-
export type
|
|
58
|
+
/** The LLM call the actor uses for its own turns/decisions/evaluation. */
|
|
59
|
+
export type ActorLLM = (
|
|
60
60
|
params: AIAgentRunnerParams
|
|
61
61
|
) => Promise<AIAgentStepResult>
|
|
62
62
|
|
|
63
63
|
export interface RunConversationParams {
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
/** Stable
|
|
67
|
-
|
|
64
|
+
/** The actor's own config (personality/jobTitle/name), which shapes how it talks. */
|
|
65
|
+
actor: ScenarioActorConfig
|
|
66
|
+
/** Stable actor name (for transcript labelling). */
|
|
67
|
+
actorName: string
|
|
68
68
|
/** What the actor is trying to get the target agent to accomplish. */
|
|
69
69
|
task: string
|
|
70
70
|
/** Natural-language success criterion the actor evaluates at the end. */
|
|
71
71
|
evaluate: string
|
|
72
72
|
/** How the actor answers the target agent's tool-approval requests. */
|
|
73
73
|
approvals?: ActorFlowApprovalPolicy
|
|
74
|
-
/** Model the
|
|
74
|
+
/** Model the actor uses for its own turns/decisions. */
|
|
75
75
|
model: string
|
|
76
76
|
/** Hard cap on conversation turns. Default 12. */
|
|
77
77
|
maxTurns?: number
|
|
@@ -79,8 +79,8 @@ export interface RunConversationParams {
|
|
|
79
79
|
maxApprovalRounds?: number
|
|
80
80
|
/** Transport that drives the target agent (HTTP in production). */
|
|
81
81
|
target: TargetAgentDriver
|
|
82
|
-
/** The
|
|
83
|
-
llm:
|
|
82
|
+
/** The actor's own LLM. */
|
|
83
|
+
llm: ActorLLM
|
|
84
84
|
/** Display name of the target agent (transcript labelling). */
|
|
85
85
|
agentName: string
|
|
86
86
|
}
|
|
@@ -109,16 +109,13 @@ function readObject<T>(result: { object?: unknown; text?: string }): T | null {
|
|
|
109
109
|
return null
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function
|
|
113
|
-
persona: ScenarioActorConfig,
|
|
114
|
-
task: string
|
|
115
|
-
): string {
|
|
112
|
+
function actorInstructions(actor: ScenarioActorConfig, task: string): string {
|
|
116
113
|
return [
|
|
117
114
|
`You are role-playing a real user interacting with an AI assistant. Stay in character at all times — you are the user, not the assistant.`,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
? `Your personality and communication style: ${
|
|
115
|
+
actor.name ? `Your name is ${actor.name}.` : '',
|
|
116
|
+
actor.jobTitle ? `Your role: ${actor.jobTitle}.` : '',
|
|
117
|
+
actor.personality
|
|
118
|
+
? `Your personality and communication style: ${actor.personality}. Match this tone, vocabulary, and level of detail exactly.`
|
|
122
119
|
: '',
|
|
123
120
|
`Your goal in this conversation: ${task}.`,
|
|
124
121
|
`Send one message at a time. Set "done" to true only once your goal is clearly accomplished, or clearly impossible.`,
|
|
@@ -127,7 +124,7 @@ function personaInstructions(
|
|
|
127
124
|
.join('\n')
|
|
128
125
|
}
|
|
129
126
|
|
|
130
|
-
/** Route the target agent's pending tool approvals through the
|
|
127
|
+
/** Route the target agent's pending tool approvals through the actor. */
|
|
131
128
|
async function decideApprovals(
|
|
132
129
|
params: RunConversationParams,
|
|
133
130
|
instructions: string,
|
|
@@ -150,7 +147,7 @@ async function decideApprovals(
|
|
|
150
147
|
|
|
151
148
|
const result = await params.llm({
|
|
152
149
|
model: params.model,
|
|
153
|
-
instructions: `${instructions}\nThe assistant is asking permission to run tools on your behalf. Decide whether YOU, as this
|
|
150
|
+
instructions: `${instructions}\nThe assistant is asking permission to run tools on your behalf. Decide whether YOU, as this actor, would allow each one.`,
|
|
154
151
|
messages: [
|
|
155
152
|
msg(
|
|
156
153
|
'user',
|
|
@@ -178,7 +175,7 @@ async function decideApprovals(
|
|
|
178
175
|
})
|
|
179
176
|
}
|
|
180
177
|
|
|
181
|
-
/** Drive the target to a non-suspended reply, routing approvals to the
|
|
178
|
+
/** Drive the target to a non-suspended reply, routing approvals to the actor. */
|
|
182
179
|
async function converseWithTarget(
|
|
183
180
|
params: RunConversationParams,
|
|
184
181
|
instructions: string,
|
|
@@ -209,7 +206,7 @@ async function converseWithTarget(
|
|
|
209
206
|
}
|
|
210
207
|
|
|
211
208
|
/**
|
|
212
|
-
* Run a conversation: an LLM-driven
|
|
209
|
+
* Run a conversation: an LLM-driven actor holds a real multi-turn exchange
|
|
213
210
|
* with a target agent (driven via the injected transport), answers the target's
|
|
214
211
|
* tool-approval requests in-persona, then evaluates whether the task was met.
|
|
215
212
|
* Deterministic checks are the caller's responsibility.
|
|
@@ -218,11 +215,11 @@ export async function runConversation(
|
|
|
218
215
|
params: RunConversationParams
|
|
219
216
|
): Promise<ActorFlowVerdict> {
|
|
220
217
|
const maxTurns = params.maxTurns ?? DEFAULT_MAX_TURNS
|
|
221
|
-
const instructions =
|
|
222
|
-
// Seed a kickoff so the very first
|
|
223
|
-
// (providers reject an empty prompt). It's an instruction TO the
|
|
218
|
+
const instructions = actorInstructions(params.actor, params.task)
|
|
219
|
+
// Seed a kickoff so the very first actor turn has a non-empty message list
|
|
220
|
+
// (providers reject an empty prompt). It's an instruction TO the actor, so
|
|
224
221
|
// it never appears in the transcript.
|
|
225
|
-
const
|
|
222
|
+
const actorMessages: AIMessage[] = [
|
|
226
223
|
msg(
|
|
227
224
|
'user',
|
|
228
225
|
'Begin the conversation now — send your first message to the assistant to work towards your goal.'
|
|
@@ -231,29 +228,27 @@ export async function runConversation(
|
|
|
231
228
|
const transcript: string[] = []
|
|
232
229
|
|
|
233
230
|
for (let turn = 0; turn < maxTurns; turn++) {
|
|
234
|
-
const
|
|
231
|
+
const actorResult = await params.llm({
|
|
235
232
|
model: params.model,
|
|
236
233
|
instructions,
|
|
237
|
-
messages:
|
|
234
|
+
messages: actorMessages,
|
|
238
235
|
tools: [],
|
|
239
236
|
maxSteps: 1,
|
|
240
237
|
toolChoice: 'none',
|
|
241
|
-
outputSchema:
|
|
238
|
+
outputSchema: ACTOR_TURN_SCHEMA as unknown as Record<string, unknown>,
|
|
242
239
|
})
|
|
243
240
|
|
|
244
|
-
const turnData = readObject<{ message: string; done: boolean }>(
|
|
245
|
-
|
|
246
|
-
)
|
|
247
|
-
const personaMessage = turnData?.message?.trim()
|
|
248
|
-
if (!personaMessage) {
|
|
241
|
+
const turnData = readObject<{ message: string; done: boolean }>(actorResult)
|
|
242
|
+
const actorMessage = turnData?.message?.trim()
|
|
243
|
+
if (!actorMessage) {
|
|
249
244
|
break
|
|
250
245
|
}
|
|
251
246
|
|
|
252
|
-
|
|
253
|
-
transcript.push(`${params.
|
|
247
|
+
actorMessages.push(msg('assistant', actorMessage))
|
|
248
|
+
transcript.push(`${params.actorName}: ${actorMessage}`)
|
|
254
249
|
|
|
255
|
-
const reply = await converseWithTarget(params, instructions,
|
|
256
|
-
|
|
250
|
+
const reply = await converseWithTarget(params, instructions, actorMessage)
|
|
251
|
+
actorMessages.push(msg('user', reply.text ?? ''))
|
|
257
252
|
transcript.push(`${params.agentName}: ${reply.text ?? ''}`)
|
|
258
253
|
|
|
259
254
|
if (turnData?.done) {
|
|
@@ -74,6 +74,20 @@ const testMeta: CLIMeta = {
|
|
|
74
74
|
positionals: [{ name: 'name', required: false }],
|
|
75
75
|
options: {},
|
|
76
76
|
},
|
|
77
|
+
serve: {
|
|
78
|
+
pikkuFuncId: 'serveFunc',
|
|
79
|
+
positionals: [],
|
|
80
|
+
options: {
|
|
81
|
+
browser: {
|
|
82
|
+
description: 'Open a browser',
|
|
83
|
+
default: true,
|
|
84
|
+
},
|
|
85
|
+
port: {
|
|
86
|
+
description: 'Port to listen on',
|
|
87
|
+
default: 8080,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
77
91
|
},
|
|
78
92
|
},
|
|
79
93
|
},
|
|
@@ -103,6 +117,52 @@ describe('Command Parser', () => {
|
|
|
103
117
|
assert.strictEqual(result.errors.length, 0)
|
|
104
118
|
})
|
|
105
119
|
|
|
120
|
+
test('should turn a boolean option off with --no-<flag>', () => {
|
|
121
|
+
const result = parseCLIArguments(
|
|
122
|
+
['serve', '--no-browser'],
|
|
123
|
+
'test-cli',
|
|
124
|
+
testMeta
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
assert.strictEqual(result.options.browser, false)
|
|
128
|
+
assert.strictEqual(result.errors.length, 0)
|
|
129
|
+
assert.deepStrictEqual(
|
|
130
|
+
result.warnings ?? [],
|
|
131
|
+
[],
|
|
132
|
+
'--no-browser is the negation of a known option, not an unknown one'
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('should leave a boolean option at its default when not negated', () => {
|
|
137
|
+
const result = parseCLIArguments(['serve'], 'test-cli', testMeta)
|
|
138
|
+
|
|
139
|
+
assert.strictEqual(result.options.browser, true)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('should not consume the next argument when negating', () => {
|
|
143
|
+
const result = parseCLIArguments(
|
|
144
|
+
['serve', '--no-browser', '--port', '4077'],
|
|
145
|
+
'test-cli',
|
|
146
|
+
testMeta
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
assert.strictEqual(result.options.browser, false)
|
|
150
|
+
assert.strictEqual(result.options.port, 4077)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('should still warn about an unknown --no-<flag>', () => {
|
|
154
|
+
const result = parseCLIArguments(
|
|
155
|
+
['serve', '--no-telemetry'],
|
|
156
|
+
'test-cli',
|
|
157
|
+
testMeta
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
assert.ok(
|
|
161
|
+
(result.warnings ?? []).length > 0,
|
|
162
|
+
'negating an option that does not exist is a typo, not a feature'
|
|
163
|
+
)
|
|
164
|
+
})
|
|
165
|
+
|
|
106
166
|
test('should parse command with short flag', () => {
|
|
107
167
|
const result = parseCLIArguments(
|
|
108
168
|
['greet', 'Alice', '-l'],
|
|
@@ -210,8 +210,19 @@ export function parseCLIArguments(
|
|
|
210
210
|
|
|
211
211
|
if (arg.startsWith('--')) {
|
|
212
212
|
// Long option (--from-plan → fromPlan)
|
|
213
|
+
const negatedKey = arg.startsWith('--no-')
|
|
214
|
+
? toCamelCase(arg.slice(5))
|
|
215
|
+
: undefined
|
|
213
216
|
const equalIndex = arg.indexOf('=')
|
|
214
|
-
if (
|
|
217
|
+
if (
|
|
218
|
+
negatedKey &&
|
|
219
|
+
typeof availableOptions[negatedKey]?.default === 'boolean'
|
|
220
|
+
) {
|
|
221
|
+
// --no-<flag> turns a boolean option off. Only options that declare a
|
|
222
|
+
// boolean default can be negated, so a literal `--no-something` option
|
|
223
|
+
// name still parses as itself.
|
|
224
|
+
optionArgs[negatedKey] = false
|
|
225
|
+
} else if (equalIndex > 0) {
|
|
215
226
|
// --option=value format
|
|
216
227
|
const key = toCamelCase(arg.slice(2, equalIndex))
|
|
217
228
|
const optionDef = availableOptions[key]
|
|
@@ -29,11 +29,13 @@ const registerFunction = (
|
|
|
29
29
|
expose,
|
|
30
30
|
pikkuFuncId,
|
|
31
31
|
tags,
|
|
32
|
+
scenarioStep,
|
|
32
33
|
}: {
|
|
33
34
|
packageName?: string | null
|
|
34
35
|
expose?: boolean
|
|
35
36
|
pikkuFuncId?: string
|
|
36
37
|
tags?: string[]
|
|
38
|
+
scenarioStep?: boolean
|
|
37
39
|
} = {}
|
|
38
40
|
) => {
|
|
39
41
|
addFunction(funcName, { func } as never, packageName)
|
|
@@ -44,6 +46,7 @@ const registerFunction = (
|
|
|
44
46
|
permissions: [],
|
|
45
47
|
expose,
|
|
46
48
|
tags,
|
|
49
|
+
scenarioStep,
|
|
47
50
|
} as never
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -312,6 +315,27 @@ describe('ContextAwareRPCService.rpcExposed', () => {
|
|
|
312
315
|
)
|
|
313
316
|
})
|
|
314
317
|
|
|
318
|
+
test('a scenario step is not callable over the network, even if marked exposed', async () => {
|
|
319
|
+
// A step may drive a browser or assert against fixtures, so it is a step
|
|
320
|
+
// RPC — dispatched by name inside a run, never reachable from outside it.
|
|
321
|
+
pikkuState(null, 'rpc', 'meta').seesAddonCard = 'seesAddonCard'
|
|
322
|
+
registerFunction('seesAddonCard', async () => ({ visible: true }), {
|
|
323
|
+
expose: true,
|
|
324
|
+
scenarioStep: true,
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
const service = new ContextAwareRPCService(
|
|
328
|
+
createServices(),
|
|
329
|
+
{} as never,
|
|
330
|
+
{}
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
await assert.rejects(
|
|
334
|
+
() => service.rpcExposed('seesAddonCard', {}),
|
|
335
|
+
RPCNotFoundError
|
|
336
|
+
)
|
|
337
|
+
})
|
|
338
|
+
|
|
315
339
|
test('runs exposed addon functions', async () => {
|
|
316
340
|
pikkuState(null, 'addons', 'packages').set('addon', {
|
|
317
341
|
package: '@addon/pkg',
|
|
@@ -669,7 +693,9 @@ describe('wireRemoteAddon dispatch', () => {
|
|
|
669
693
|
{}
|
|
670
694
|
)
|
|
671
695
|
|
|
672
|
-
const result = await service.rpc('registry:getOpenApi', {
|
|
696
|
+
const result = await service.rpc('registry:getOpenApi', {
|
|
697
|
+
name: 'stripe',
|
|
698
|
+
})
|
|
673
699
|
|
|
674
700
|
assert.deepEqual(result, { echoed: 42 })
|
|
675
701
|
assert.equal(calls.length, 1)
|
|
@@ -713,10 +739,7 @@ describe('wireRemoteAddon dispatch', () => {
|
|
|
713
739
|
)
|
|
714
740
|
|
|
715
741
|
await service.rpc('registry:getOpenApi', {})
|
|
716
|
-
assert.equal(
|
|
717
|
-
calls[0]!.init.headers.authorization,
|
|
718
|
-
'Bearer user-token-9'
|
|
719
|
-
)
|
|
742
|
+
assert.equal(calls[0]!.init.headers.authorization, 'Bearer user-token-9')
|
|
720
743
|
} finally {
|
|
721
744
|
restoreFetch()
|
|
722
745
|
}
|
|
@@ -165,7 +165,7 @@ export class ContextAwareRPCService {
|
|
|
165
165
|
if (!functionMeta) {
|
|
166
166
|
throw new RPCNotFoundError(funcName)
|
|
167
167
|
}
|
|
168
|
-
if (!functionMeta.expose) {
|
|
168
|
+
if (!functionMeta.expose || functionMeta.scenarioStep) {
|
|
169
169
|
throw new RPCNotFoundError(funcName)
|
|
170
170
|
}
|
|
171
171
|
return await this.rpc(funcName, data)
|
|
@@ -7,6 +7,10 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
|
7
7
|
|
|
8
8
|
import type { WorkflowRun } from '../workflow.types.js'
|
|
9
9
|
import type { ScenarioActor } from '../../../services/scenario-actors-service.js'
|
|
10
|
+
import type {
|
|
11
|
+
ScenarioStepOptions,
|
|
12
|
+
ScenarioStepPhase,
|
|
13
|
+
} from '../scenario-step.types.js'
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* Workflow step options
|
|
@@ -79,6 +83,19 @@ export type WorkflowWireDoInline = <T>(
|
|
|
79
83
|
options?: WorkflowStepOptions
|
|
80
84
|
) => Promise<T>
|
|
81
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Type signature for scenario.step/given/when/then - used by inspector.
|
|
88
|
+
*
|
|
89
|
+
* Deliberately mirrors WorkflowWireDoRPC's shape: the target is a string, not
|
|
90
|
+
* an imported symbol, so the extractor reads it as a literal.
|
|
91
|
+
*/
|
|
92
|
+
export type ScenarioStepInvocation = <TOutput = any, TInput = any>(
|
|
93
|
+
stepName: string,
|
|
94
|
+
stepFunc: string,
|
|
95
|
+
data?: TInput,
|
|
96
|
+
options?: ScenarioStepOptions
|
|
97
|
+
) => Promise<TOutput>
|
|
98
|
+
|
|
82
99
|
/**
|
|
83
100
|
* Type signature for workflow.sleep() - used by inspector
|
|
84
101
|
*/
|
|
@@ -180,6 +197,34 @@ export interface RpcStepMeta {
|
|
|
180
197
|
expectEventually?: boolean
|
|
181
198
|
}
|
|
182
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Scenario step metadata — a call to `scenario.step/given/when/then`.
|
|
202
|
+
*
|
|
203
|
+
* Distinct from RpcStepMeta on purpose: a step runs locally through
|
|
204
|
+
* runPikkuFunc and must never be treated as dispatchable on the queue/replay
|
|
205
|
+
* path, nor registered as a callable RPC (a browser-driving step must not be
|
|
206
|
+
* network-invocable).
|
|
207
|
+
*/
|
|
208
|
+
export interface ScenarioStepMeta {
|
|
209
|
+
type: 'scenarioStep'
|
|
210
|
+
/** Cache key (first argument), ordinal-suffixed by the engine when repeated */
|
|
211
|
+
stepName: string
|
|
212
|
+
/** Registered name of the step function being run */
|
|
213
|
+
stepFunc: string
|
|
214
|
+
/** Which keyword the reporter prefixes — given/when/then, or none for `step` */
|
|
215
|
+
phase: ScenarioStepPhase
|
|
216
|
+
/** Output variable name (if assigned) */
|
|
217
|
+
outputVar?: string
|
|
218
|
+
/** Input source mappings, or 'passthrough' when entire data is passed */
|
|
219
|
+
inputs?: Record<string, InputSource> | 'passthrough'
|
|
220
|
+
/** Step options */
|
|
221
|
+
options?: WorkflowStepOptions
|
|
222
|
+
/** Scenario actor name this step runs as ({ actor: actors.x }) */
|
|
223
|
+
actor?: string
|
|
224
|
+
/** Mirrors the step function's `browser: true` declaration */
|
|
225
|
+
browser?: boolean
|
|
226
|
+
}
|
|
227
|
+
|
|
183
228
|
/**
|
|
184
229
|
* Simple condition expression (leaf node)
|
|
185
230
|
*/
|
|
@@ -223,7 +268,7 @@ export interface BranchStepMeta {
|
|
|
223
268
|
export interface ParallelGroupStepMeta {
|
|
224
269
|
type: 'parallel'
|
|
225
270
|
/** Child steps to execute in parallel */
|
|
226
|
-
children: RpcStepMeta
|
|
271
|
+
children: Array<RpcStepMeta | ScenarioStepMeta>
|
|
227
272
|
}
|
|
228
273
|
|
|
229
274
|
/**
|
|
@@ -244,7 +289,7 @@ export interface FanoutStepMeta {
|
|
|
244
289
|
/** Execution mode */
|
|
245
290
|
mode: 'parallel' | 'sequential'
|
|
246
291
|
/** Steps to execute inline per iteration, in order */
|
|
247
|
-
body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta>
|
|
292
|
+
body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta | ScenarioStepMeta>
|
|
248
293
|
/** Time between iterations (sequential mode only) */
|
|
249
294
|
timeBetween?: string
|
|
250
295
|
}
|
|
@@ -409,6 +454,7 @@ export interface ArrayPredicateStepMeta {
|
|
|
409
454
|
*/
|
|
410
455
|
export type WorkflowStepMeta =
|
|
411
456
|
| RpcStepMeta
|
|
457
|
+
| ScenarioStepMeta
|
|
412
458
|
| BranchStepMeta
|
|
413
459
|
| ParallelGroupStepMeta
|
|
414
460
|
| FanoutStepMeta
|
|
@@ -509,5 +555,43 @@ export interface PikkuScenarioWire extends PikkuWorkflowWire {
|
|
|
509
555
|
options?: WorkflowExpectServiceOptions
|
|
510
556
|
) => Promise<void>
|
|
511
557
|
|
|
558
|
+
/**
|
|
559
|
+
* Run a registered scenario step. Shaped exactly like `do`'s RPC form —
|
|
560
|
+
* `(stepName, target, data, options)` — so the inspector reads the target as
|
|
561
|
+
* a string literal rather than resolving an imported symbol.
|
|
562
|
+
*
|
|
563
|
+
* The generated `TypedScenario` narrows these over `FlattenedScenarioStepMap`.
|
|
564
|
+
*/
|
|
565
|
+
step(
|
|
566
|
+
stepName: string,
|
|
567
|
+
stepFunc: string,
|
|
568
|
+
data?: any,
|
|
569
|
+
options?: ScenarioStepOptions
|
|
570
|
+
): Promise<any>
|
|
571
|
+
|
|
572
|
+
/** `step` with a "Given" prefix in the rendered prose */
|
|
573
|
+
given(
|
|
574
|
+
stepName: string,
|
|
575
|
+
stepFunc: string,
|
|
576
|
+
data?: any,
|
|
577
|
+
options?: ScenarioStepOptions
|
|
578
|
+
): Promise<any>
|
|
579
|
+
|
|
580
|
+
/** `step` with a "When" prefix in the rendered prose */
|
|
581
|
+
when(
|
|
582
|
+
stepName: string,
|
|
583
|
+
stepFunc: string,
|
|
584
|
+
data?: any,
|
|
585
|
+
options?: ScenarioStepOptions
|
|
586
|
+
): Promise<any>
|
|
587
|
+
|
|
588
|
+
/** `step` with a "Then" prefix in the rendered prose */
|
|
589
|
+
then(
|
|
590
|
+
stepName: string,
|
|
591
|
+
stepFunc: string,
|
|
592
|
+
data?: any,
|
|
593
|
+
options?: ScenarioStepOptions
|
|
594
|
+
): Promise<any>
|
|
595
|
+
|
|
512
596
|
runScheduledTask: (name: string) => Promise<unknown>
|
|
513
597
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { resolveFeatureScenarios } from './feature.js'
|
|
5
|
+
import type { CoreFeature, CoreWorkflow } from './workflow.types.js'
|
|
6
|
+
|
|
7
|
+
const scenario = (tags: string[] = []) =>
|
|
8
|
+
({ tags, func: async () => ({}) }) as any
|
|
9
|
+
|
|
10
|
+
const registered = (entries: Array<[string, any]>): Map<string, CoreWorkflow> =>
|
|
11
|
+
new Map(entries.map(([name, func]) => [name, { name, func }]))
|
|
12
|
+
|
|
13
|
+
describe('resolveFeatureScenarios', () => {
|
|
14
|
+
test('resolves imported identifiers to the names they are registered under', () => {
|
|
15
|
+
const lazyLoad = scenario()
|
|
16
|
+
const roundTrip = scenario()
|
|
17
|
+
const features = new Map<string, CoreFeature>([
|
|
18
|
+
[
|
|
19
|
+
'credentialFeature',
|
|
20
|
+
{
|
|
21
|
+
name: 'Credential API',
|
|
22
|
+
scenarios: [lazyLoad, roundTrip],
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
const { entries, unresolved } = resolveFeatureScenarios(
|
|
28
|
+
features,
|
|
29
|
+
registered([
|
|
30
|
+
['credentialLazyLoadScenario', lazyLoad],
|
|
31
|
+
['credentialRoundTripScenario', roundTrip],
|
|
32
|
+
])
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(unresolved, [])
|
|
36
|
+
assert.deepEqual(
|
|
37
|
+
entries.map((e) => e.scenarioName),
|
|
38
|
+
['credentialLazyLoadScenario', 'credentialRoundTripScenario']
|
|
39
|
+
)
|
|
40
|
+
assert.equal(entries[0]!.featureName, 'Credential API')
|
|
41
|
+
assert.equal(entries[0]!.featureId, 'credentialFeature')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('keeps declaration order, including repeats of the same scenario', () => {
|
|
45
|
+
const roundTrip = scenario()
|
|
46
|
+
const features = new Map<string, CoreFeature>([
|
|
47
|
+
[
|
|
48
|
+
'credentialFeature',
|
|
49
|
+
{
|
|
50
|
+
name: 'Credential API',
|
|
51
|
+
scenarios: ['stripe', 'google', 'hmac-key'].map((name) => ({
|
|
52
|
+
scenario: roundTrip,
|
|
53
|
+
data: { name },
|
|
54
|
+
})),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
const { entries } = resolveFeatureScenarios(
|
|
60
|
+
features,
|
|
61
|
+
registered([['credentialRoundTripScenario', roundTrip]])
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
assert.equal(entries.length, 3, 'a mapped loop is three separate runs')
|
|
65
|
+
assert.deepEqual(
|
|
66
|
+
entries.map((e) => e.data),
|
|
67
|
+
[{ name: 'stripe' }, { name: 'google' }, { name: 'hmac-key' }]
|
|
68
|
+
)
|
|
69
|
+
assert.ok(
|
|
70
|
+
entries.every((e) => e.scenarioName === 'credentialRoundTripScenario')
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('a bare reference carries no data', () => {
|
|
75
|
+
const lazyLoad = scenario()
|
|
76
|
+
const { entries } = resolveFeatureScenarios(
|
|
77
|
+
new Map([['f', { name: 'F', scenarios: [lazyLoad] } as CoreFeature]]),
|
|
78
|
+
registered([['lazyLoadScenario', lazyLoad]])
|
|
79
|
+
)
|
|
80
|
+
assert.equal(entries[0]!.data, undefined)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("a scenario's effective tags union its own with the feature's", () => {
|
|
84
|
+
const lazyLoad = scenario(['scenario', 'credential'])
|
|
85
|
+
const { entries } = resolveFeatureScenarios(
|
|
86
|
+
new Map([
|
|
87
|
+
[
|
|
88
|
+
'f',
|
|
89
|
+
{
|
|
90
|
+
name: 'F',
|
|
91
|
+
tags: ['nightly', 'credential'],
|
|
92
|
+
scenarios: [lazyLoad],
|
|
93
|
+
} as CoreFeature,
|
|
94
|
+
],
|
|
95
|
+
]),
|
|
96
|
+
registered([['lazyLoadScenario', lazyLoad]])
|
|
97
|
+
)
|
|
98
|
+
assert.deepEqual(entries[0]!.tags, ['scenario', 'credential', 'nightly'])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('an unregistered scenario is reported, never silently matched by shape', () => {
|
|
102
|
+
const registeredScenario = scenario()
|
|
103
|
+
const lookalike = scenario()
|
|
104
|
+
const { entries, unresolved } = resolveFeatureScenarios(
|
|
105
|
+
new Map([
|
|
106
|
+
[
|
|
107
|
+
'f',
|
|
108
|
+
{
|
|
109
|
+
name: 'F',
|
|
110
|
+
scenarios: [registeredScenario, lookalike],
|
|
111
|
+
} as CoreFeature,
|
|
112
|
+
],
|
|
113
|
+
]),
|
|
114
|
+
registered([['realScenario', registeredScenario]])
|
|
115
|
+
)
|
|
116
|
+
assert.deepEqual(
|
|
117
|
+
entries.map((e) => e.scenarioName),
|
|
118
|
+
['realScenario']
|
|
119
|
+
)
|
|
120
|
+
assert.deepEqual(unresolved, [{ featureId: 'f', index: 1 }])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('a feature with no scenarios contributes nothing', () => {
|
|
124
|
+
const { entries, unresolved } = resolveFeatureScenarios(
|
|
125
|
+
new Map([['f', { name: 'F', scenarios: [] } as CoreFeature]]),
|
|
126
|
+
registered([])
|
|
127
|
+
)
|
|
128
|
+
assert.deepEqual(entries, [])
|
|
129
|
+
assert.deepEqual(unresolved, [])
|
|
130
|
+
})
|
|
131
|
+
})
|