@pikku/core 0.12.69 → 0.12.71
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 +422 -0
- package/LICENSE +21 -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/in-memory-queue-service.d.ts +6 -0
- package/dist/services/in-memory-queue-service.js +8 -1
- package/dist/services/in-memory-workflow-service.d.ts +3 -5
- package/dist/services/in-memory-workflow-service.js +10 -19
- 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/services/workflow-service.d.ts +7 -5
- package/dist/types/core.types.d.ts +28 -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/ai-agent/ai-agent-agui.js +0 -8
- package/dist/wirings/ai-agent/ai-agent-prepare.js +1 -2
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +0 -6
- 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/graph/graph-runner.js +3 -2
- package/dist/wirings/workflow/graph/graph-validation.d.ts +0 -2
- package/dist/wirings/workflow/graph/graph-validation.js +0 -142
- package/dist/wirings/workflow/graph/index.d.ts +1 -1
- package/dist/wirings/workflow/graph/index.js +1 -1
- package/dist/wirings/workflow/index.d.ts +13 -3
- package/dist/wirings/workflow/index.js +15 -2
- 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 +170 -23
- package/dist/wirings/workflow/pikku-workflow-service.js +338 -297
- 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 +82 -8
- 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/in-memory-queue-service.test.ts +66 -1
- package/src/services/in-memory-queue-service.ts +13 -2
- package/src/services/in-memory-workflow-service.ts +12 -25
- 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/services/workflow-service.ts +7 -4
- package/src/types/core.types.ts +34 -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/ai-agent/ai-agent-agui.test.ts +0 -16
- package/src/wirings/ai-agent/ai-agent-agui.ts +0 -9
- package/src/wirings/ai-agent/ai-agent-prepare.ts +1 -2
- package/src/wirings/ai-agent/ai-agent.types.ts +0 -7
- 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/graph/graph-runner.ts +3 -2
- package/src/wirings/workflow/graph/graph-validation.test.ts +1 -144
- package/src/wirings/workflow/graph/graph-validation.ts +0 -196
- package/src/wirings/workflow/graph/index.ts +1 -5
- package/src/wirings/workflow/index.ts +73 -6
- 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 +572 -419
- 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-expectations.test.ts +153 -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 +442 -9
- package/src/wirings/workflow/scenario-step.types.ts +157 -0
- package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
- package/src/wirings/workflow/workflow-dispatch-payload.test.ts +59 -0
- package/src/wirings/workflow/workflow-mirror.test.ts +178 -0
- package/src/wirings/workflow/workflow-replay-snapshot.test.ts +139 -0
- package/src/wirings/workflow/workflow-run-context.test.ts +177 -0
- package/src/wirings/workflow/workflow-run-polling.test.ts +132 -0
- package/src/wirings/workflow/workflow-step-ordinal.test.ts +4 -4
- package/src/wirings/workflow/workflow.types.ts +99 -5
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -38,11 +38,27 @@ const startTarget = async () => {
|
|
|
38
38
|
res.writeHead(401).end()
|
|
39
39
|
return
|
|
40
40
|
}
|
|
41
|
+
const rpcName = req.url.slice('/api/rpc/'.length)
|
|
42
|
+
if (rpcName === 'html-error') {
|
|
43
|
+
res
|
|
44
|
+
.writeHead(500, { 'content-type': 'text/html' })
|
|
45
|
+
.end('<html><body>Gateway blew up</body></html>')
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (rpcName === 'forbidden') {
|
|
49
|
+
res
|
|
50
|
+
.writeHead(403, { 'content-type': 'application/json' })
|
|
51
|
+
.end(
|
|
52
|
+
JSON.stringify({ message: 'MissingScopeError', scope: 'admin' })
|
|
53
|
+
)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
41
56
|
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
42
57
|
JSON.stringify({
|
|
43
|
-
rpcName
|
|
58
|
+
rpcName,
|
|
44
59
|
echoed: body.data,
|
|
45
60
|
cookie,
|
|
61
|
+
userHeader: req.headers['x-user-id'] ?? null,
|
|
46
62
|
})
|
|
47
63
|
)
|
|
48
64
|
return
|
|
@@ -115,6 +131,74 @@ describe('HttpScenarioActor', async () => {
|
|
|
115
131
|
assert.match(result.cookie, new RegExp(`session=s${loginsBefore + 1}`))
|
|
116
132
|
})
|
|
117
133
|
|
|
134
|
+
test('invokeRaw returns the status and body instead of throwing', async () => {
|
|
135
|
+
const actors = makeActors()
|
|
136
|
+
|
|
137
|
+
// A refusal is the expected outcome of a permissions scenario, so the
|
|
138
|
+
// status and the payload that names the missing scope both have to survive.
|
|
139
|
+
const res = await actors.customer!.invokeRaw('forbidden', {})
|
|
140
|
+
|
|
141
|
+
assert.equal(res.status, 403)
|
|
142
|
+
assert.equal(res.ok, false)
|
|
143
|
+
assert.deepEqual(res.body, {
|
|
144
|
+
message: 'MissingScopeError',
|
|
145
|
+
scope: 'admin',
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('invokeRaw reports a success the same way', async () => {
|
|
150
|
+
const actors = makeActors()
|
|
151
|
+
const res = await actors.manager!.invokeRaw('listTodos', { page: 2 })
|
|
152
|
+
|
|
153
|
+
assert.equal(res.status, 200)
|
|
154
|
+
assert.equal(res.ok, true)
|
|
155
|
+
assert.deepEqual((res.body as any).echoed, { page: 2 })
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('invokeRaw carries the response text so a step can search it', async () => {
|
|
159
|
+
const actors = makeActors()
|
|
160
|
+
const res = await actors.customer!.invokeRaw('forbidden', {})
|
|
161
|
+
|
|
162
|
+
assert.match(res.serialized, /MissingScopeError/)
|
|
163
|
+
assert.match(res.serialized, /admin/)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('invokeRaw keeps a non-JSON error body instead of failing to parse it', async () => {
|
|
167
|
+
const actors = makeActors()
|
|
168
|
+
const res = await actors.manager!.invokeRaw('html-error', {})
|
|
169
|
+
|
|
170
|
+
assert.equal(res.status, 500)
|
|
171
|
+
assert.equal(res.ok, false)
|
|
172
|
+
assert.equal(res.body, '<html><body>Gateway blew up</body></html>')
|
|
173
|
+
assert.match(res.serialized, /Gateway blew up/)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('invokeRaw reports an empty body as an empty string', async () => {
|
|
177
|
+
const actors = makeActors()
|
|
178
|
+
const res = await actors.manager!.invokeRaw('listTodos', {})
|
|
179
|
+
|
|
180
|
+
assert.equal(typeof res.serialized, 'string')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('invokeRaw passes extra headers through', async () => {
|
|
184
|
+
const actors = makeActors()
|
|
185
|
+
const res = await actors.manager!.invokeRaw(
|
|
186
|
+
'whoAmI',
|
|
187
|
+
{},
|
|
188
|
+
{ headers: { 'x-user-id': 'impersonated-1' } }
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
assert.equal((res.body as any).userHeader, 'impersonated-1')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('invoke still throws on a refusal, naming the status and body', async () => {
|
|
195
|
+
const actors = makeActors()
|
|
196
|
+
await assert.rejects(
|
|
197
|
+
actors.customer!.invoke('forbidden', {}),
|
|
198
|
+
/'forbidden' as 'customer' returned 403.*MissingScopeError/
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
|
|
118
202
|
test('a wrong impersonation secret surfaces status and body', async () => {
|
|
119
203
|
const actors = makeActors('wrong-secret')
|
|
120
204
|
await assert.rejects(
|
|
@@ -2,13 +2,20 @@ import type {
|
|
|
2
2
|
ScenarioActor,
|
|
3
3
|
ScenarioActorConfig,
|
|
4
4
|
ScenarioActors,
|
|
5
|
+
ScenarioInvokeOptions,
|
|
6
|
+
ScenarioHttpResponse,
|
|
5
7
|
} from './scenario-actors-service.js'
|
|
8
|
+
import { readScenarioHttpResponse } from './scenario-actors-service.js'
|
|
6
9
|
import type {
|
|
7
10
|
ConverseOptions,
|
|
8
11
|
ActorFlowVerdict,
|
|
9
12
|
TargetAgentReply,
|
|
10
13
|
} from '../wirings/actor-flow/actor-flow.types.js'
|
|
11
14
|
import { runConversation } from '../wirings/actor-flow/run-conversation.js'
|
|
15
|
+
import {
|
|
16
|
+
createCookieJar,
|
|
17
|
+
type ScenarioCookieJar,
|
|
18
|
+
} from '../wirings/workflow/scenario-cookie-jar.js'
|
|
12
19
|
import { getSingletonServices } from '../pikku-state.js'
|
|
13
20
|
import { AIProviderNotConfiguredError } from '../errors/errors.js'
|
|
14
21
|
|
|
@@ -48,15 +55,21 @@ export interface HttpScenarioActorsConfig {
|
|
|
48
55
|
* outlive a session).
|
|
49
56
|
*/
|
|
50
57
|
export class HttpScenarioActor implements ScenarioActor {
|
|
51
|
-
private
|
|
52
|
-
|
|
58
|
+
private jar: ScenarioCookieJar
|
|
59
|
+
/**
|
|
60
|
+
* Whether `login()` has succeeded since the last time the session was
|
|
61
|
+
* dropped. The jar cannot answer this — a target may set a cookie before
|
|
62
|
+
* anyone signs in, and it would then look like a session that was never
|
|
63
|
+
* established.
|
|
64
|
+
*/
|
|
65
|
+
private signedIn = false
|
|
53
66
|
|
|
54
67
|
constructor(
|
|
55
68
|
readonly name: string,
|
|
56
69
|
private actorConfig: ScenarioActorConfig,
|
|
57
70
|
private config: HttpScenarioActorsConfig
|
|
58
71
|
) {
|
|
59
|
-
this.
|
|
72
|
+
this.jar = createCookieJar(config.apiUrl)
|
|
60
73
|
}
|
|
61
74
|
|
|
62
75
|
get email(): string {
|
|
@@ -64,17 +77,31 @@ export class HttpScenarioActor implements ScenarioActor {
|
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
async invoke(rpcName: string, data: unknown): Promise<unknown> {
|
|
67
|
-
const
|
|
68
|
-
|
|
80
|
+
const res = await this.invokeRaw(rpcName, data)
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`[scenario] '${rpcName}' as '${this.name}' returned ${res.status}: ${res.serialized.slice(0, 300)}`
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
return res.body
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async invokeRaw(
|
|
90
|
+
rpcName: string,
|
|
91
|
+
data: unknown,
|
|
92
|
+
options?: ScenarioInvokeOptions
|
|
93
|
+
): Promise<ScenarioHttpResponse> {
|
|
94
|
+
if (!this.signedIn) {
|
|
95
|
+
await this.login()
|
|
96
|
+
}
|
|
97
|
+
let res = await this.postRpc(rpcName, data, options?.headers)
|
|
69
98
|
if (res.status === 401) {
|
|
70
99
|
// Session expired mid-run — re-login once and retry.
|
|
71
|
-
this.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
await this.postRpc(rpcName, data, await this.login())
|
|
75
|
-
)
|
|
100
|
+
this.signOut()
|
|
101
|
+
await this.login()
|
|
102
|
+
res = await this.postRpc(rpcName, data, options?.headers)
|
|
76
103
|
}
|
|
77
|
-
return
|
|
104
|
+
return readScenarioHttpResponse(res)
|
|
78
105
|
}
|
|
79
106
|
|
|
80
107
|
async converse(options: ConverseOptions): Promise<ActorFlowVerdict> {
|
|
@@ -92,8 +119,8 @@ export class HttpScenarioActor implements ScenarioActor {
|
|
|
92
119
|
const resourceId = `actor:${this.name}`
|
|
93
120
|
|
|
94
121
|
return runConversation({
|
|
95
|
-
|
|
96
|
-
|
|
122
|
+
actor: this.actorConfig,
|
|
123
|
+
actorName: this.actorConfig.name ?? this.name,
|
|
97
124
|
agentName: options.agent,
|
|
98
125
|
task: options.task,
|
|
99
126
|
evaluate: options.evaluate,
|
|
@@ -148,21 +175,18 @@ export class HttpScenarioActor implements ScenarioActor {
|
|
|
148
175
|
private async postAgent(subPath: string, body: unknown): Promise<unknown> {
|
|
149
176
|
const rpcPath = this.config.rpcPath ?? '/rpc'
|
|
150
177
|
const url = `${this.config.apiUrl}${rpcPath}/${subPath}`
|
|
151
|
-
const send = (
|
|
152
|
-
fetch(url, {
|
|
178
|
+
const send = () =>
|
|
179
|
+
this.jar.fetch(url, {
|
|
153
180
|
method: 'POST',
|
|
154
|
-
headers: {
|
|
155
|
-
'content-type': 'application/json',
|
|
156
|
-
origin: this.origin,
|
|
157
|
-
...(cookie ? { cookie } : {}),
|
|
158
|
-
},
|
|
181
|
+
headers: { 'content-type': 'application/json' },
|
|
159
182
|
body: JSON.stringify(body),
|
|
160
183
|
})
|
|
161
184
|
|
|
162
|
-
let res = await send(
|
|
185
|
+
let res = await send()
|
|
163
186
|
if (res.status === 401) {
|
|
164
|
-
this.
|
|
165
|
-
|
|
187
|
+
this.signOut()
|
|
188
|
+
await this.login()
|
|
189
|
+
res = await send()
|
|
166
190
|
}
|
|
167
191
|
if (!res.ok) {
|
|
168
192
|
const text = (await res.text().catch(() => '')).slice(0, 300)
|
|
@@ -175,36 +199,30 @@ export class HttpScenarioActor implements ScenarioActor {
|
|
|
175
199
|
return text ? JSON.parse(text) : undefined
|
|
176
200
|
}
|
|
177
201
|
|
|
178
|
-
private async postRpc(
|
|
202
|
+
private async postRpc(
|
|
203
|
+
rpcName: string,
|
|
204
|
+
data: unknown,
|
|
205
|
+
extraHeaders?: Record<string, string>
|
|
206
|
+
) {
|
|
179
207
|
const rpcPath = this.config.rpcPath ?? '/rpc'
|
|
180
|
-
return fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
|
|
208
|
+
return this.jar.fetch(`${this.config.apiUrl}${rpcPath}/${rpcName}`, {
|
|
181
209
|
method: 'POST',
|
|
182
|
-
headers: {
|
|
183
|
-
'content-type': 'application/json',
|
|
184
|
-
origin: this.origin,
|
|
185
|
-
cookie,
|
|
186
|
-
},
|
|
210
|
+
headers: { 'content-type': 'application/json', ...extraHeaders },
|
|
187
211
|
body: JSON.stringify({ data }),
|
|
188
212
|
})
|
|
189
213
|
}
|
|
190
214
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
`[scenario] '${rpcName}' as '${this.name}' returned ${res.status}: ${body}`
|
|
196
|
-
)
|
|
197
|
-
}
|
|
198
|
-
if (res.status === 204) return undefined
|
|
199
|
-
const text = await res.text()
|
|
200
|
-
return text ? JSON.parse(text) : undefined
|
|
215
|
+
/** Drop the session, so the next call signs in again before it goes out. */
|
|
216
|
+
private signOut(): void {
|
|
217
|
+
this.jar.clear()
|
|
218
|
+
this.signedIn = false
|
|
201
219
|
}
|
|
202
220
|
|
|
203
|
-
private async login(): Promise<
|
|
221
|
+
private async login(): Promise<void> {
|
|
204
222
|
const signInPath = this.config.signInPath ?? '/auth/sign-in/actor'
|
|
205
|
-
const res = await fetch(`${this.config.apiUrl}${signInPath}`, {
|
|
223
|
+
const res = await this.jar.fetch(`${this.config.apiUrl}${signInPath}`, {
|
|
206
224
|
method: 'POST',
|
|
207
|
-
headers: { 'content-type': 'application/json'
|
|
225
|
+
headers: { 'content-type': 'application/json' },
|
|
208
226
|
body: JSON.stringify({
|
|
209
227
|
email: this.actorConfig.email,
|
|
210
228
|
name: this.actorConfig.name ?? this.name,
|
|
@@ -217,18 +235,14 @@ export class HttpScenarioActor implements ScenarioActor {
|
|
|
217
235
|
`[scenario] actor sign-in failed for '${this.name}' (${res.status}): ${body}`
|
|
218
236
|
)
|
|
219
237
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
.filter(Boolean)
|
|
224
|
-
.join('; ')
|
|
225
|
-
if (!cookie) {
|
|
238
|
+
// What proves a session was established is this response setting a cookie,
|
|
239
|
+
// not the jar being non-empty — the target may have set one earlier.
|
|
240
|
+
if (res.headers.getSetCookie().length === 0) {
|
|
226
241
|
throw new Error(
|
|
227
242
|
`[scenario] actor sign-in for '${this.name}' returned no session cookie`
|
|
228
243
|
)
|
|
229
244
|
}
|
|
230
|
-
this.
|
|
231
|
-
return cookie
|
|
245
|
+
this.signedIn = true
|
|
232
246
|
}
|
|
233
247
|
}
|
|
234
248
|
|
|
@@ -23,7 +23,10 @@ const registerWorker = (
|
|
|
23
23
|
) => {
|
|
24
24
|
const calls = { count: 0 }
|
|
25
25
|
const funcId = `queue_${queueName}`
|
|
26
|
-
pikkuState(null, 'queue', 'meta')[queueName] = {
|
|
26
|
+
pikkuState(null, 'queue', 'meta')[queueName] = {
|
|
27
|
+
pikkuFuncId: funcId,
|
|
28
|
+
name: queueName,
|
|
29
|
+
}
|
|
27
30
|
pikkuState(null, 'function', 'meta')[funcId] = {
|
|
28
31
|
pikkuFuncId: funcId,
|
|
29
32
|
inputSchemaName: null,
|
|
@@ -55,6 +58,68 @@ const waitUntil = async (
|
|
|
55
58
|
return predicate()
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
// Register a queue worker that records the payload each delivery carried.
|
|
62
|
+
const registerRecordingWorker = (queueName: string) => {
|
|
63
|
+
const received: any[] = []
|
|
64
|
+
const funcId = `queue_${queueName}`
|
|
65
|
+
pikkuState(null, 'queue', 'meta')[queueName] = {
|
|
66
|
+
pikkuFuncId: funcId,
|
|
67
|
+
name: queueName,
|
|
68
|
+
}
|
|
69
|
+
pikkuState(null, 'function', 'meta')[funcId] = {
|
|
70
|
+
pikkuFuncId: funcId,
|
|
71
|
+
inputSchemaName: null,
|
|
72
|
+
outputSchemaName: null,
|
|
73
|
+
sessionless: true,
|
|
74
|
+
} as any
|
|
75
|
+
wireQueueWorker({
|
|
76
|
+
name: queueName,
|
|
77
|
+
func: {
|
|
78
|
+
auth: false,
|
|
79
|
+
func: async (_services: any, data: any) => {
|
|
80
|
+
received.push(data)
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
} as any)
|
|
84
|
+
return received
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('InMemoryQueueService payload isolation', () => {
|
|
88
|
+
test('a job carries the payload as it was when added', async () => {
|
|
89
|
+
const received = registerRecordingWorker('mutated')
|
|
90
|
+
const queue = new InMemoryQueueService()
|
|
91
|
+
const payload = { items: ['a'] }
|
|
92
|
+
|
|
93
|
+
await queue.add('mutated', payload, { delay: 0 })
|
|
94
|
+
payload.items.push('b')
|
|
95
|
+
|
|
96
|
+
assert.equal(await waitUntil(() => received.length === 1), true)
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
received[0].items,
|
|
99
|
+
['a'],
|
|
100
|
+
'the worker saw a mutation made after the job was queued — a real queue would have serialised it away'
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('a payload arrives shaped the way a real queue would deliver it', async () => {
|
|
105
|
+
const received = registerRecordingWorker('serialised')
|
|
106
|
+
const queue = new InMemoryQueueService()
|
|
107
|
+
|
|
108
|
+
await queue.add(
|
|
109
|
+
'serialised',
|
|
110
|
+
{ when: new Date('2020-01-01T00:00:00.000Z') },
|
|
111
|
+
{ delay: 0 }
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
assert.equal(await waitUntil(() => received.length === 1), true)
|
|
115
|
+
assert.equal(
|
|
116
|
+
received[0].when,
|
|
117
|
+
'2020-01-01T00:00:00.000Z',
|
|
118
|
+
'a Date survived as a Date, so dev behaviour diverges from every real queue backend'
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
58
123
|
describe('InMemoryQueueService retry', () => {
|
|
59
124
|
test('redelivers a transiently-failing job until it succeeds', async () => {
|
|
60
125
|
const calls = registerWorker('flaky', (count) => {
|
|
@@ -11,6 +11,12 @@ import { runQueueJob } from '../wirings/queue/queue-runner.js'
|
|
|
11
11
|
* a real queue — and redelivers a failed job up to `options.attempts` times with
|
|
12
12
|
* backoff, so a transiently-failing workflow step recovers exactly as it would
|
|
13
13
|
* on pg-boss/bullmq instead of being silently dropped on its first error.
|
|
14
|
+
*
|
|
15
|
+
* Payloads are JSON round-tripped on the way in, because every real backend
|
|
16
|
+
* puts the job on a wire (SQS body, Redis value, jsonb column) and the worker
|
|
17
|
+
* therefore never sees the caller's live object. Doing it here keeps dev
|
|
18
|
+
* behaviour honest, and keeps the callers — who cannot know which backend they
|
|
19
|
+
* are talking to — from having to serialise defensively.
|
|
14
20
|
*/
|
|
15
21
|
export class InMemoryQueueService implements QueueService {
|
|
16
22
|
readonly supportsResults = false
|
|
@@ -25,13 +31,15 @@ export class InMemoryQueueService implements QueueService {
|
|
|
25
31
|
const maxAttempts = Math.max(1, options?.attempts ?? 1)
|
|
26
32
|
let attemptsMade = 0
|
|
27
33
|
const createdAt = new Date()
|
|
34
|
+
const payload: T =
|
|
35
|
+
data === undefined ? data : (JSON.parse(JSON.stringify(data)) as T)
|
|
28
36
|
|
|
29
37
|
const runAttempt = async () => {
|
|
30
38
|
attemptsMade++
|
|
31
39
|
const job: QueueJob<T> = {
|
|
32
40
|
id: jobId,
|
|
33
41
|
queueName,
|
|
34
|
-
data,
|
|
42
|
+
data: payload,
|
|
35
43
|
status: () => 'active',
|
|
36
44
|
metadata: () => ({ attemptsMade, maxAttempts, createdAt }),
|
|
37
45
|
pikkuUserId: options?.pikkuUserId,
|
|
@@ -41,7 +49,10 @@ export class InMemoryQueueService implements QueueService {
|
|
|
41
49
|
} catch (e: any) {
|
|
42
50
|
if (attemptsMade < maxAttempts) {
|
|
43
51
|
// Transient failure — redeliver with backoff, mirroring a real queue.
|
|
44
|
-
setTimeout(
|
|
52
|
+
setTimeout(
|
|
53
|
+
runAttempt,
|
|
54
|
+
this.backoffDelay(options?.backoff, attemptsMade)
|
|
55
|
+
)
|
|
45
56
|
} else {
|
|
46
57
|
console.error(
|
|
47
58
|
`[InMemoryQueue] Job ${jobId} on ${queueName} failed after ${attemptsMade} attempt(s):`,
|
|
@@ -456,6 +456,18 @@ export class InMemoryWorkflowService
|
|
|
456
456
|
return nodeIds.filter((id) => !existingSteps.has(id))
|
|
457
457
|
}
|
|
458
458
|
|
|
459
|
+
protected override async listStepStates(
|
|
460
|
+
runId: string
|
|
461
|
+
): Promise<Array<StepState & { stepName: string }>> {
|
|
462
|
+
const prefix = `${runId}:`
|
|
463
|
+
const steps: Array<StepState & { stepName: string }> = []
|
|
464
|
+
for (const [key, step] of this.steps.entries()) {
|
|
465
|
+
if (!key.startsWith(prefix)) continue
|
|
466
|
+
steps.push({ ...step, stepName: key.substring(prefix.length) })
|
|
467
|
+
}
|
|
468
|
+
return steps
|
|
469
|
+
}
|
|
470
|
+
|
|
459
471
|
async getStepInstances(
|
|
460
472
|
runId: string
|
|
461
473
|
): Promise<
|
|
@@ -560,29 +572,4 @@ export class InMemoryWorkflowService
|
|
|
560
572
|
if (!version) return null
|
|
561
573
|
return { graph: version.graph, source: version.source }
|
|
562
574
|
}
|
|
563
|
-
|
|
564
|
-
async getAIGeneratedWorkflows(
|
|
565
|
-
agentName?: string
|
|
566
|
-
): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>> {
|
|
567
|
-
const results: Array<{
|
|
568
|
-
workflowName: string
|
|
569
|
-
graphHash: string
|
|
570
|
-
graph: any
|
|
571
|
-
}> = []
|
|
572
|
-
const prefix = agentName ? `ai:${agentName}:` : 'ai:'
|
|
573
|
-
for (const [key, value] of this.workflowVersions) {
|
|
574
|
-
if (value.source !== 'ai-agent' || value.status !== 'active') continue
|
|
575
|
-
const separatorIdx = key.lastIndexOf(':')
|
|
576
|
-
const wfName = key.substring(0, separatorIdx)
|
|
577
|
-
const hash = key.substring(separatorIdx + 1)
|
|
578
|
-
if (wfName.startsWith(prefix)) {
|
|
579
|
-
results.push({
|
|
580
|
-
workflowName: wfName,
|
|
581
|
-
graphHash: hash,
|
|
582
|
-
graph: value.graph,
|
|
583
|
-
})
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
return results
|
|
587
|
-
}
|
|
588
575
|
}
|
package/src/services/index.ts
CHANGED
|
@@ -43,8 +43,13 @@ export type {
|
|
|
43
43
|
export type {
|
|
44
44
|
ScenarioActor,
|
|
45
45
|
ScenarioActorConfig,
|
|
46
|
+
ScenarioActorOf,
|
|
46
47
|
ScenarioActors,
|
|
48
|
+
ScenarioInvokeOptions,
|
|
49
|
+
ScenarioRpcMap,
|
|
50
|
+
ScenarioHttpResponse,
|
|
47
51
|
} from './scenario-actors-service.js'
|
|
52
|
+
export { readScenarioHttpResponse } from './scenario-actors-service.js'
|
|
48
53
|
export {
|
|
49
54
|
HttpScenarioActor,
|
|
50
55
|
createHttpScenarioActors,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, test, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { LocalMetaService } from './meta-service.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Scenarios and their steps are generated into `.pikku/scenarios/` so that no
|
|
11
|
+
* app-facing module imports them. Everything that reads meta off disk — the
|
|
12
|
+
* console's scenario list among them — must still see them.
|
|
13
|
+
*/
|
|
14
|
+
describe('LocalMetaService reads the scenario meta alongside the app meta', () => {
|
|
15
|
+
let pikkuDir: string
|
|
16
|
+
|
|
17
|
+
before(async () => {
|
|
18
|
+
pikkuDir = await mkdtemp(join(tmpdir(), 'pikku-meta-'))
|
|
19
|
+
await mkdir(join(pikkuDir, 'workflow', 'meta'), { recursive: true })
|
|
20
|
+
await mkdir(join(pikkuDir, 'scenarios', 'meta'), { recursive: true })
|
|
21
|
+
await mkdir(join(pikkuDir, 'function'), { recursive: true })
|
|
22
|
+
await mkdir(join(pikkuDir, 'scenarios'), { recursive: true })
|
|
23
|
+
|
|
24
|
+
await writeFile(
|
|
25
|
+
join(pikkuDir, 'workflow', 'meta', 'orderWorkflow.gen.json'),
|
|
26
|
+
JSON.stringify({ name: 'orderWorkflow', source: 'dsl' })
|
|
27
|
+
)
|
|
28
|
+
await writeFile(
|
|
29
|
+
join(pikkuDir, 'scenarios', 'meta', 'codeEditorScenario.gen.json'),
|
|
30
|
+
JSON.stringify({ name: 'codeEditorScenario', source: 'scenario' })
|
|
31
|
+
)
|
|
32
|
+
await writeFile(
|
|
33
|
+
join(pikkuDir, 'function', 'pikku-functions-meta.gen.json'),
|
|
34
|
+
JSON.stringify({ createTodo: { pikkuFuncId: 'createTodo' } })
|
|
35
|
+
)
|
|
36
|
+
await writeFile(
|
|
37
|
+
join(pikkuDir, 'scenarios', 'pikku-scenario-functions-meta.gen.json'),
|
|
38
|
+
JSON.stringify({
|
|
39
|
+
opensPage: { pikkuFuncId: 'opensPage', scenarioStep: true },
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
after(async () => {
|
|
45
|
+
await rm(pikkuDir, { recursive: true, force: true })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('a scenario is still a workflow to whoever reads the meta', async () => {
|
|
49
|
+
const meta = await new LocalMetaService(pikkuDir).getWorkflowMeta()
|
|
50
|
+
assert.deepEqual(Object.keys(meta).sort(), [
|
|
51
|
+
'codeEditorScenario',
|
|
52
|
+
'orderWorkflow',
|
|
53
|
+
])
|
|
54
|
+
assert.equal(meta['codeEditorScenario']!.source, 'scenario')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('a scenario step is still a function to whoever reads the meta', async () => {
|
|
58
|
+
const meta = await new LocalMetaService(pikkuDir).getFunctionsMeta()
|
|
59
|
+
assert.deepEqual(Object.keys(meta).sort(), ['createTodo', 'opensPage'])
|
|
60
|
+
assert.equal(meta['opensPage']!.scenarioStep, true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('a project with no scenarios reads exactly what it has', async () => {
|
|
64
|
+
const empty = await mkdtemp(join(tmpdir(), 'pikku-meta-empty-'))
|
|
65
|
+
await mkdir(join(empty, 'workflow', 'meta'), { recursive: true })
|
|
66
|
+
await writeFile(
|
|
67
|
+
join(empty, 'workflow', 'meta', 'orderWorkflow.gen.json'),
|
|
68
|
+
JSON.stringify({ name: 'orderWorkflow', source: 'dsl' })
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const service = new LocalMetaService(empty)
|
|
72
|
+
assert.deepEqual(Object.keys(await service.getWorkflowMeta()), [
|
|
73
|
+
'orderWorkflow',
|
|
74
|
+
])
|
|
75
|
+
assert.deepEqual(Object.keys(await service.getFunctionsMeta()), [])
|
|
76
|
+
|
|
77
|
+
await rm(empty, { recursive: true, force: true })
|
|
78
|
+
})
|
|
79
|
+
})
|