@pikku/core 0.12.74 → 0.12.77
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 +378 -0
- package/dist/column-form.d.ts +32 -0
- package/dist/column-form.js +42 -0
- package/dist/crypto-utils.d.ts +15 -4
- package/dist/crypto-utils.js +18 -2
- package/dist/data-classification.d.ts +44 -0
- package/dist/function/functions.types.d.ts +23 -10
- package/dist/function/index.d.ts +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/middleware/auth-bearer.js +2 -1
- package/dist/middleware/remote-auth.js +1 -1
- package/dist/remote.js +1 -1
- package/dist/secret-value.d.ts +56 -0
- package/dist/secret-value.js +46 -0
- package/dist/services/audit-service.d.ts +74 -4
- package/dist/services/audit-service.js +7 -5
- package/dist/services/credential-wire-service.d.ts +5 -0
- package/dist/services/credential-wire-service.js +9 -1
- package/dist/services/email-service.d.ts +2 -1
- package/dist/services/index.d.ts +3 -3
- package/dist/services/index.js +1 -1
- package/dist/services/local-content-request-handler.d.ts +29 -0
- package/dist/services/local-content-request-handler.js +176 -0
- package/dist/services/local-secrets.d.ts +4 -3
- package/dist/services/local-secrets.js +7 -3
- package/dist/services/logger.d.ts +22 -5
- package/dist/services/queue-webhook-service.js +1 -1
- package/dist/services/scoped-secret-service.d.ts +4 -3
- package/dist/services/secret-service.d.ts +8 -3
- package/dist/services/typed-secret-service.d.ts +5 -4
- package/dist/services/webhook-service.d.ts +2 -1
- package/dist/testing/service-tests.js +6 -6
- package/dist/types/core.types.d.ts +25 -4
- package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
- package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
- package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
- package/dist/wirings/ai-agent/index.d.ts +1 -1
- package/dist/wirings/ai-agent/index.js +1 -1
- package/dist/wirings/ai-agent/voice-input.d.ts +20 -0
- package/dist/wirings/ai-agent/voice-input.js +44 -9
- package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
- package/dist/wirings/ai-agent/voice-output.js +10 -1
- package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
- package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
- package/dist/wirings/cli/channel/index.d.ts +1 -0
- package/dist/wirings/persona/define-personas.d.ts +4 -0
- package/dist/wirings/persona/define-personas.js +4 -0
- package/dist/wirings/persona/persona.types.d.ts +11 -0
- package/dist/wirings/queue/queue-identity.js +2 -1
- package/dist/wirings/queue/queue.types.d.ts +2 -1
- package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
- package/dist/wirings/rpc/remote-addon-auth.js +6 -2
- package/dist/wirings/virtual-user/index.d.ts +3 -0
- package/dist/wirings/virtual-user/index.js +2 -0
- package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
- package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
- package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
- package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
- package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
- package/dist/wirings/workflow/scenario-prose.js +1 -1
- package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
- package/dist/wirings/workflow/workflow.types.d.ts +7 -0
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
- package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
- package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
- package/knowledge/decisions/internals/index.md +4 -0
- package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
- package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
- package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
- package/package.json +3 -2
- package/src/column-form.test.ts +97 -0
- package/src/column-form.ts +58 -0
- package/src/crypto-utils.ts +25 -6
- package/src/data-classification.ts +44 -0
- package/src/function/functions.types.ts +47 -10
- package/src/function/index.ts +1 -0
- package/src/index.ts +24 -2
- package/src/middleware/auth-bearer.test.ts +3 -2
- package/src/middleware/auth-bearer.ts +2 -1
- package/src/middleware/remote-auth.test.ts +2 -1
- package/src/middleware/remote-auth.ts +1 -1
- package/src/remote.test.ts +2 -1
- package/src/remote.ts +1 -1
- package/src/secret-value.test.ts +204 -0
- package/src/secret-value.ts +111 -0
- package/src/services/audit-service.ts +87 -9
- package/src/services/credential-wire-service.ts +9 -1
- package/src/services/email-service.ts +3 -1
- package/src/services/index.ts +3 -3
- package/src/services/local-content-request-handler.test.ts +202 -0
- package/src/services/local-content-request-handler.ts +267 -0
- package/src/services/local-secrets.test.ts +20 -5
- package/src/services/local-secrets.ts +15 -7
- package/src/services/logger.ts +27 -7
- package/src/services/queue-webhook-service.test.ts +2 -1
- package/src/services/queue-webhook-service.ts +1 -1
- package/src/services/scoped-secret-service.ts +4 -3
- package/src/services/secret-service.ts +8 -3
- package/src/services/typed-secret-service.ts +11 -7
- package/src/services/webhook-service.ts +4 -1
- package/src/testing/service-tests.ts +6 -6
- package/src/types/core.types.ts +25 -4
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
- package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
- package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
- package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
- package/src/wirings/ai-agent/index.ts +2 -0
- package/src/wirings/ai-agent/voice-input.test.ts +65 -0
- package/src/wirings/ai-agent/voice-input.ts +48 -9
- package/src/wirings/ai-agent/voice-output.test.ts +91 -1
- package/src/wirings/ai-agent/voice-output.ts +28 -1
- package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
- package/src/wirings/cli/channel/index.ts +4 -0
- package/src/wirings/persona/define-personas.ts +4 -0
- package/src/wirings/persona/persona.types.ts +11 -0
- package/src/wirings/queue/queue-identity.test.ts +2 -1
- package/src/wirings/queue/queue-identity.ts +4 -1
- package/src/wirings/queue/queue.types.ts +6 -1
- package/src/wirings/queue/signed-queue-service.ts +2 -1
- package/src/wirings/rpc/remote-addon-auth.ts +8 -3
- package/src/wirings/rpc/rpc-runner.test.ts +6 -4
- package/src/wirings/virtual-user/index.ts +12 -0
- package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
- package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
- package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
- package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
- package/src/wirings/workflow/scenario-prose.test.ts +5 -7
- package/src/wirings/workflow/scenario-prose.ts +1 -1
- package/src/wirings/workflow/scenario-service.test.ts +0 -1
- package/src/wirings/workflow/scenario-step.test.ts +4 -5
- package/src/wirings/workflow/scenario-step.types.ts +13 -7
- package/src/wirings/workflow/scenario-surface.test.ts +6 -5
- package/src/wirings/workflow/workflow.types.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/tsconfig.type-tests.json +12 -0
|
@@ -58,22 +58,44 @@ async function fetchAsUint8Array(url, allowedAudioHosts) {
|
|
|
58
58
|
}
|
|
59
59
|
return new Uint8Array(buffer);
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Shared-notes key recording whether the user's turn arrived as audio.
|
|
63
|
+
*
|
|
64
|
+
* Written here because this is the last point at which it is knowable: the
|
|
65
|
+
* transcript that replaces the audio is indistinguishable from something typed.
|
|
66
|
+
* Read by `voiceOutput` to decide whether to answer aloud.
|
|
67
|
+
*
|
|
68
|
+
* Absent — rather than `false` — when this middleware is not wired at all, which
|
|
69
|
+
* is what lets `voiceOutput` still speak for a caller that has no voice input.
|
|
70
|
+
*/
|
|
71
|
+
export const SPOKEN_TURN = 'voice:spokenTurn';
|
|
72
|
+
/**
|
|
73
|
+
* Shared-notes key holding what the user was heard to say, when they spoke.
|
|
74
|
+
*
|
|
75
|
+
* Absent on a typed turn. Read by the stream wiring, which forwards it to the
|
|
76
|
+
* client as a `transcript` event — a voice client has no idea what its own
|
|
77
|
+
* audio said, and without this the user's turn shows up in the UI as an empty
|
|
78
|
+
* bubble followed by an answer to a question they cannot see.
|
|
79
|
+
*/
|
|
80
|
+
export const SPOKEN_TRANSCRIPT = 'voice:transcript';
|
|
61
81
|
export const voiceInput = (config) => pikkuAIMiddleware({
|
|
62
|
-
modifyInput: async (services, { messages, instructions }) => {
|
|
82
|
+
modifyInput: async (services, { messages, instructions, shared }) => {
|
|
63
83
|
const aiAgentRunner = services.aiAgentRunner;
|
|
64
84
|
if (!aiAgentRunner?.transcribe)
|
|
65
85
|
return { messages, instructions };
|
|
66
86
|
const last = messages[messages.length - 1];
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
87
|
+
const parts = last?.role === 'user' && typeof last.content !== 'string'
|
|
88
|
+
? last.content
|
|
89
|
+
: undefined;
|
|
90
|
+
const hasAudio = !!parts?.some((p) => p.type === 'file' && !!p.mediaType?.startsWith('audio/'));
|
|
91
|
+
// Recorded on every turn this middleware sees, spoken or not, so that a
|
|
92
|
+
// typed turn is an explicit `false` downstream rather than a silence that
|
|
93
|
+
// could equally mean nobody was asked.
|
|
94
|
+
shared[SPOKEN_TURN] = hasAudio;
|
|
95
|
+
if (!hasAudio || !parts)
|
|
75
96
|
return { messages, instructions };
|
|
76
97
|
const updatedContent = [];
|
|
98
|
+
const heard = [];
|
|
77
99
|
for (const p of parts) {
|
|
78
100
|
if (!(p.type === 'file' && p.mediaType?.startsWith('audio/'))) {
|
|
79
101
|
updatedContent.push(p);
|
|
@@ -85,6 +107,9 @@ export const voiceInput = (config) => pikkuAIMiddleware({
|
|
|
85
107
|
const audioData = p.data
|
|
86
108
|
? base64ToUint8Array(p.data)
|
|
87
109
|
: await fetchAsUint8Array(p.url, config.allowedAudioHosts);
|
|
110
|
+
if (audioData.byteLength > MAX_AUDIO_SIZE) {
|
|
111
|
+
throw new Error('Audio file exceeds maximum size');
|
|
112
|
+
}
|
|
88
113
|
const result = await aiAgentRunner.transcribe({
|
|
89
114
|
model: config.model,
|
|
90
115
|
audio: audioData,
|
|
@@ -103,12 +128,22 @@ export const voiceInput = (config) => pikkuAIMiddleware({
|
|
|
103
128
|
// not hear.
|
|
104
129
|
if (readsAsNonSpeech(result))
|
|
105
130
|
continue;
|
|
131
|
+
heard.push(result.text);
|
|
106
132
|
updatedContent.push({ type: 'text', text: result.text });
|
|
107
133
|
}
|
|
108
134
|
// Every part was audio, and none of it was speech. There is nothing left
|
|
109
135
|
// to send, and a message with no content is not a question.
|
|
110
136
|
if (updatedContent.length === 0)
|
|
111
137
|
throw new NoSpeechDetectedError();
|
|
138
|
+
// Only when something was actually heard. A turn can carry audio that all
|
|
139
|
+
// reads as non-speech and still have content — an image with a silent
|
|
140
|
+
// caption clip — which leaves nothing above to throw. Recording `''` here
|
|
141
|
+
// would send a transcript event saying the user said nothing, and a client
|
|
142
|
+
// that tells "not transcribed yet" from "transcribed" by the key being
|
|
143
|
+
// absent would render the turn as an empty bubble rather than a pending one.
|
|
144
|
+
if (heard.length > 0) {
|
|
145
|
+
shared[SPOKEN_TRANSCRIPT] = heard.join(' ');
|
|
146
|
+
}
|
|
112
147
|
return {
|
|
113
148
|
messages: [
|
|
114
149
|
...messages.slice(0, -1),
|
|
@@ -59,6 +59,21 @@ export declare const voiceOutput: (config?: {
|
|
|
59
59
|
* the right default for a provider that genuinely is multilingual.
|
|
60
60
|
*/
|
|
61
61
|
speakableScripts?: SpeakableScripts;
|
|
62
|
+
/**
|
|
63
|
+
* Speak every reply, including answers to turns that were typed.
|
|
64
|
+
*
|
|
65
|
+
* The default is to speak only what was spoken to. An agent is usually one
|
|
66
|
+
* agent serving both kinds of caller — the same thread is typed at from a
|
|
67
|
+
* desk and talked to from a phone — and synthesizing a reply nobody is
|
|
68
|
+
* listening to costs real money on every sentence of every turn. Whether the
|
|
69
|
+
* user is in a call is not something the client needs to declare: sending
|
|
70
|
+
* audio is what saying so looks like.
|
|
71
|
+
*
|
|
72
|
+
* Set this for the cases where speech is not a reply to speech at all — a
|
|
73
|
+
* read-aloud accessibility mode, a kiosk, anything whose whole output is
|
|
74
|
+
* meant to be heard.
|
|
75
|
+
*/
|
|
76
|
+
always?: boolean;
|
|
62
77
|
}) => import("./ai-agent.types.js").PikkuAIMiddlewareHooks<VoiceOutputState, import("../../types/core.types.js").CoreSingletonServices<{
|
|
63
78
|
logLevel?: import("../../services/logger.js").LogLevel;
|
|
64
79
|
secrets?: {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { pikkuAIMiddleware } from '../../types/core.types.js';
|
|
2
|
+
import { SPOKEN_TURN } from './voice-input.js';
|
|
2
3
|
function bufferToBase64(data) {
|
|
3
4
|
let binary = '';
|
|
4
5
|
for (let i = 0; i < data.length; i++) {
|
|
@@ -100,10 +101,17 @@ async function synthesizeAudio(aiAgentRunner, input) {
|
|
|
100
101
|
};
|
|
101
102
|
}
|
|
102
103
|
export const voiceOutput = (config) => pikkuAIMiddleware({
|
|
103
|
-
modifyOutputStream: async (services, { event, state, emit, signal }) => {
|
|
104
|
+
modifyOutputStream: async (services, { event, state, shared, emit, signal }) => {
|
|
104
105
|
const { aiAgentRunner, logger } = services;
|
|
105
106
|
if (!aiAgentRunner?.generateSpeech)
|
|
106
107
|
return event;
|
|
108
|
+
// Only an explicit `false` silences the reply. `voiceInput` sets this on
|
|
109
|
+
// every turn it handles, so `false` means a real user really typed; the
|
|
110
|
+
// key being absent means nothing reported either way — no voice input is
|
|
111
|
+
// wired — and that caller's replies are spoken exactly as they were before
|
|
112
|
+
// this option existed.
|
|
113
|
+
if (!config?.always && shared[SPOKEN_TURN] === false)
|
|
114
|
+
return event;
|
|
107
115
|
/**
|
|
108
116
|
* Queue a finished sentence for speech.
|
|
109
117
|
*
|
|
@@ -167,6 +175,7 @@ export const voiceOutput = (config) => pikkuAIMiddleware({
|
|
|
167
175
|
type: 'audio-delta',
|
|
168
176
|
data: bufferToBase64(result.audio.bytes),
|
|
169
177
|
format: result.audio.format,
|
|
178
|
+
text,
|
|
170
179
|
});
|
|
171
180
|
});
|
|
172
181
|
};
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import type { ApprovalRequester, Capabilities } from '../../channel/channel-rpc.js';
|
|
2
|
-
import type {
|
|
2
|
+
import type { Logger } from '../../../services/logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* Everything a renderer gets when the command ran on the server: a logger, and
|
|
5
|
+
* nothing else. There is no container on this side of the socket to resolve a
|
|
6
|
+
* service from, so this is the whole of it rather than a subset of
|
|
7
|
+
* `CoreSingletonServices` — which requires `config`, `variables` and `secrets`
|
|
8
|
+
* the client does not have and cannot invent.
|
|
9
|
+
*/
|
|
10
|
+
export type ClientCLIRenderServices = {
|
|
11
|
+
logger: Logger;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* A renderer that runs on the client of a CLI-over-channel connection.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately not `CorePikkuCLIRender`: that type's `Services` parameter is
|
|
17
|
+
* constrained to `CoreSingletonServices`, so the honest logger-only shape
|
|
18
|
+
* cannot be spelled through it.
|
|
19
|
+
*/
|
|
20
|
+
export type CorePikkuCLIClientRender<Data> = (services: ClientCLIRenderServices, data: Data, session?: undefined) => void | Promise<void>;
|
|
3
21
|
/**
|
|
4
22
|
* Runs a CLI command entirely on the server. Unlike `executeCLIViaChannel`,
|
|
5
23
|
* argv is forwarded untouched and never parsed here, so the client carries no
|
|
@@ -13,8 +31,8 @@ import type { CorePikkuCLIRender } from '../cli.types.js';
|
|
|
13
31
|
export declare function executeRawCLIViaChannel({ pikkuWS, args, renderers, defaultRenderer, capabilities, approve, }: {
|
|
14
32
|
pikkuWS: any;
|
|
15
33
|
args?: string[];
|
|
16
|
-
renderers?: Record<string,
|
|
17
|
-
defaultRenderer?:
|
|
34
|
+
renderers?: Record<string, CorePikkuCLIClientRender<any>>;
|
|
35
|
+
defaultRenderer?: CorePikkuCLIClientRender<any>;
|
|
18
36
|
capabilities?: Capabilities;
|
|
19
37
|
approve?: ApprovalRequester;
|
|
20
38
|
}): Promise<number>;
|
|
@@ -4,11 +4,19 @@ import { approverForMode, takeApprovalFlags } from './cli-approval.js';
|
|
|
4
4
|
const defaultJSONRenderer = (_services, data) => {
|
|
5
5
|
console.log(JSON.stringify(data));
|
|
6
6
|
};
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
// `console` is the whole logger a CLI wants — its output is the command's output.
|
|
8
|
+
// `setLevel` is a no-op rather than an omission because `Logger` requires it and
|
|
9
|
+
// there is no level to set: a renderer writes what the user asked to see.
|
|
10
|
+
const clientServices = {
|
|
11
|
+
logger: {
|
|
12
|
+
info: (...args) => console.info(...args),
|
|
13
|
+
warn: (...args) => console.warn(...args),
|
|
14
|
+
error: (...args) => console.error(...args),
|
|
15
|
+
debug: (...args) => console.debug(...args),
|
|
16
|
+
trace: (...args) => console.trace(...args),
|
|
17
|
+
setLevel: () => { },
|
|
18
|
+
},
|
|
19
|
+
};
|
|
12
20
|
/**
|
|
13
21
|
* Runs a CLI command entirely on the server. Unlike `executeCLIViaChannel`,
|
|
14
22
|
* argv is forwarded untouched and never parsed here, so the client carries no
|
|
@@ -2,5 +2,6 @@ export { executeCLIViaChannel } from './cli-channel-runner.js';
|
|
|
2
2
|
export { handleRawCLI } from './cli-raw-channel-runner.js';
|
|
3
3
|
export type { RawCLIFrame, RawCLIResult } from './cli-raw-channel-runner.js';
|
|
4
4
|
export { executeRawCLIViaChannel } from './cli-raw-client-runner.js';
|
|
5
|
+
export type { ClientCLIRenderServices, CorePikkuCLIClientRender, } from './cli-raw-client-runner.js';
|
|
5
6
|
export { APPROVAL_FLAGS, approverForMode, createTerminalApprover, takeApprovalFlags, } from './cli-approval.js';
|
|
6
7
|
export type { ApprovalMode } from './cli-approval.js';
|
|
@@ -10,6 +10,10 @@ import type { CorePersonas } from './persona.types.js';
|
|
|
10
10
|
* running: a persona that exists only to be acted upon (the account an admin
|
|
11
11
|
* bans, the colleague a document is shared with) is seeded and never run.
|
|
12
12
|
*
|
|
13
|
+
* Exactly one `definePersonas(...)` is allowed per codebase, so there is one
|
|
14
|
+
* place to read the cast from and one place to add to. A second call — even in
|
|
15
|
+
* the same file — fails the build.
|
|
16
|
+
*
|
|
13
17
|
* @example
|
|
14
18
|
* ```typescript
|
|
15
19
|
* definePersonas({
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* running: a persona that exists only to be acted upon (the account an admin
|
|
10
10
|
* bans, the colleague a document is shared with) is seeded and never run.
|
|
11
11
|
*
|
|
12
|
+
* Exactly one `definePersonas(...)` is allowed per codebase, so there is one
|
|
13
|
+
* place to read the cast from and one place to add to. A second call — even in
|
|
14
|
+
* the same file — fails the build.
|
|
15
|
+
*
|
|
12
16
|
* @example
|
|
13
17
|
* ```typescript
|
|
14
18
|
* definePersonas({
|
|
@@ -36,6 +36,16 @@ export type CorePersona = {
|
|
|
36
36
|
name: string;
|
|
37
37
|
jobTitle?: string;
|
|
38
38
|
description?: string;
|
|
39
|
+
/**
|
|
40
|
+
* A picture of this person, as any URL a browser can load: a file your app
|
|
41
|
+
* serves, an asset on a CDN, a Gravatar link.
|
|
42
|
+
*
|
|
43
|
+
* Optional because it is decoration. Omitted, the console draws a colour and
|
|
44
|
+
* an icon derived from the persona's id, which is stable, needs no network and
|
|
45
|
+
* is more legible than the identicon a synthetic address would resolve to —
|
|
46
|
+
* which is also why nothing is derived here.
|
|
47
|
+
*/
|
|
48
|
+
avatarUrl?: string;
|
|
39
49
|
/**
|
|
40
50
|
* The roles this person holds. Only system roles may be named — a custom
|
|
41
51
|
* role can be deleted from the console, so a persona pinned to one silently
|
|
@@ -107,6 +117,7 @@ export type PersonaMeta = {
|
|
|
107
117
|
name: string;
|
|
108
118
|
jobTitle?: string;
|
|
109
119
|
description?: string;
|
|
120
|
+
avatarUrl?: string;
|
|
110
121
|
roles: string[];
|
|
111
122
|
personality?: string;
|
|
112
123
|
goals: string[];
|
|
@@ -25,7 +25,8 @@ const canonicalizePayload = (data) => stableStringify(JSON.parse(JSON.stringify(
|
|
|
25
25
|
const signingPayload = (encodedClaim, data) => `${QUEUE_IDENTITY_CLAIM_VERSION}.${encodedClaim}.${canonicalizePayload(data)}`;
|
|
26
26
|
export const getQueueIdentitySecret = async (secrets) => {
|
|
27
27
|
try {
|
|
28
|
-
return (await secrets?.getSecret(QUEUE_IDENTITY_SECRET_NAME)) ||
|
|
28
|
+
return ((await secrets?.getSecret(QUEUE_IDENTITY_SECRET_NAME))?.reveal() ||
|
|
29
|
+
undefined);
|
|
29
30
|
}
|
|
30
31
|
catch {
|
|
31
32
|
return undefined;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CommonWireMeta } from '../../types/core.types.js';
|
|
2
2
|
import type { CorePikkuFunctionConfig } from '../../function/functions.types.js';
|
|
3
3
|
import type { QueueConfigMapping } from './validate-worker-config.js';
|
|
4
|
+
import type { Safe } from '../../secret-value.js';
|
|
4
5
|
export interface PikkuWorkerConfig {
|
|
5
6
|
name?: string;
|
|
6
7
|
/** Total worker concurrency. */
|
|
@@ -95,7 +96,7 @@ export interface JobOptions {
|
|
|
95
96
|
}
|
|
96
97
|
export interface QueueService {
|
|
97
98
|
readonly supportsResults: boolean;
|
|
98
|
-
add<T>(queueName: string, data: T
|
|
99
|
+
add<T>(queueName: string, data: Safe<T>, options?: JobOptions): Promise<string>;
|
|
99
100
|
getJob<T, R>(queueName: string, jobId: string): Promise<QueueJob<T, R> | null>;
|
|
100
101
|
}
|
|
101
102
|
export interface QueueWorkers {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Safe } from '../../secret-value.js';
|
|
1
2
|
import type { Logger } from '../../services/logger.js';
|
|
2
3
|
import type { SecretService } from '../../services/secret-service.js';
|
|
3
4
|
import type { JobOptions, QueueJob, QueueService } from './queue.types.js';
|
|
@@ -11,6 +12,6 @@ export declare class SignedQueueService implements QueueService {
|
|
|
11
12
|
private readonly logger;
|
|
12
13
|
constructor(queueService: QueueService, secrets: SecretService, logger: Logger);
|
|
13
14
|
get supportsResults(): boolean;
|
|
14
|
-
add<T>(queueName: string, data: T
|
|
15
|
+
add<T>(queueName: string, data: Safe<T>, options?: JobOptions): Promise<string>;
|
|
15
16
|
getJob<T, R>(queueName: string, jobId: string): Promise<QueueJob<T, R> | null>;
|
|
16
17
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { CoreServices, PikkuRawWire } from '../../types/core.types.js';
|
|
2
2
|
import { PikkuError } from '../../errors/error-handler.js';
|
|
3
|
+
import { type SecretValue } from '../../secret-value.js';
|
|
3
4
|
export type RemoteAddonAuthBinding = {
|
|
4
5
|
credentialId: string;
|
|
5
6
|
} | {
|
|
6
7
|
secretId: string;
|
|
7
8
|
} | {
|
|
8
|
-
resolve: (services: CoreServices, wire: PikkuRawWire) => string | Promise<string
|
|
9
|
+
resolve: (services: CoreServices, wire: PikkuRawWire) => string | SecretValue<string> | Promise<string | SecretValue<string>>;
|
|
9
10
|
};
|
|
10
11
|
export declare class RemoteAddonAuthError extends PikkuError {
|
|
11
12
|
readonly namespace: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PikkuError, addError } from '../../errors/error-handler.js';
|
|
2
|
+
import { isSecretValue } from '../../secret-value.js';
|
|
2
3
|
export class RemoteAddonAuthError extends PikkuError {
|
|
3
4
|
namespace;
|
|
4
5
|
constructor(namespace, detail) {
|
|
@@ -28,8 +29,11 @@ export async function resolveRemoteAddonToken(auth, services, wire, namespace) {
|
|
|
28
29
|
else if (typeof auth.resolve === 'function') {
|
|
29
30
|
token = await auth.resolve(services, wire);
|
|
30
31
|
}
|
|
31
|
-
|
|
32
|
+
// The wire is where a secret is meant to end up, so unwrap it here rather
|
|
33
|
+
// than at each branch — `resolve` and `getCredential` may hand one back too.
|
|
34
|
+
const resolved = isSecretValue(token) ? token.reveal() : token;
|
|
35
|
+
if (resolved === null || resolved === undefined || resolved === '') {
|
|
32
36
|
throw new RemoteAddonAuthError(namespace, 'resolved token was empty');
|
|
33
37
|
}
|
|
34
|
-
return String(
|
|
38
|
+
return String(resolved);
|
|
35
39
|
}
|
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
* signed in as a real user against staging or production.
|
|
18
18
|
*/
|
|
19
19
|
export type { ApiCatalogueEntry, IntentRecord, IntentSource, IntentStatus, StepRecord, VirtualUserAction, VirtualUserBudget, VirtualUserDisposition, VirtualUserFinding, VirtualUserFindingKind, VirtualUserRunResult, VirtualUserTally, VirtualUserTarget, } from './virtual-user.types.js';
|
|
20
|
+
export { PRODUCTION_DISPOSITION } from './virtual-user.types.js';
|
|
20
21
|
export { runVirtualUser, rememberIds, type RunVirtualUserParams, type VirtualUserCallContext, } from './run-virtual-user.js';
|
|
22
|
+
export { personaScopes, prepareVirtualUserRun, type VirtualUserPreparation, } from './prepare-virtual-user-run.js';
|
|
23
|
+
export type { VirtualUserRunOutcome, VirtualUserRunRecord, VirtualUserRunStart, VirtualUserRunStore, } from './virtual-user-run-store.js';
|
|
21
24
|
export { DISPOSITIONS, dispositionProfile, type DispositionProfile, type VirtualUserTuning, } from './virtual-user-dispositions.js';
|
|
22
25
|
export { catalogueClassification, catalogueIndex, catalogueLookup, describeEntry, isReadOnly, reachableCatalogue, renderCatalogue, unreachableCatalogue, } from './virtual-user-catalogue.js';
|
|
23
26
|
export { reachableAgents, type AgentReachability, type ReachableAgent, } from './virtual-user-agents.js';
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
export { PRODUCTION_DISPOSITION } from './virtual-user.types.js';
|
|
1
2
|
export { runVirtualUser, rememberIds, } from './run-virtual-user.js';
|
|
3
|
+
export { personaScopes, prepareVirtualUserRun, } from './prepare-virtual-user-run.js';
|
|
2
4
|
export { DISPOSITIONS, dispositionProfile, } from './virtual-user-dispositions.js';
|
|
3
5
|
export { catalogueClassification, catalogueIndex, catalogueLookup, describeEntry, isReadOnly, reachableCatalogue, renderCatalogue, unreachableCatalogue, } from './virtual-user-catalogue.js';
|
|
4
6
|
export { reachableAgents, } from './virtual-user-agents.js';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { FunctionsMeta } from '../../types/core.types.js';
|
|
2
|
+
import type { WorkflowsMeta } from '../workflow/workflow.types.js';
|
|
3
|
+
import type { SystemRoleDefinitions, SystemRoleDefinitionsMeta } from '../role/role.types.js';
|
|
4
|
+
import { type SchemaMap } from './virtual-user-derive.js';
|
|
5
|
+
import { type AgentReachability, type ReachableAgent } from './virtual-user-agents.js';
|
|
6
|
+
import type { ApiCatalogueEntry, IntentSource } from './virtual-user.types.js';
|
|
7
|
+
/**
|
|
8
|
+
* The scopes a persona holds, resolved through its roles.
|
|
9
|
+
*
|
|
10
|
+
* Roles are the only thing a persona declares; scopes are what a function
|
|
11
|
+
* checks. Narrowing a virtual user's catalogue needs the second, so the
|
|
12
|
+
* expansion happens once, here, against the same `defineSystemRole` definitions
|
|
13
|
+
* the seed grants from.
|
|
14
|
+
*/
|
|
15
|
+
export declare const personaScopes: (persona: {
|
|
16
|
+
roles?: readonly string[];
|
|
17
|
+
}, roleScopes: Record<string, readonly string[]>) => string[];
|
|
18
|
+
/** Everything a run needs that is derived rather than decided. */
|
|
19
|
+
export interface VirtualUserPreparation {
|
|
20
|
+
/** Every RPC the app exposes, narrowed to what this persona can reach. */
|
|
21
|
+
catalogue: ApiCatalogueEntry[];
|
|
22
|
+
intents: IntentSource[];
|
|
23
|
+
scopes: string[];
|
|
24
|
+
agents: ReachableAgent[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Derive what a virtual user needs from what the project already generates.
|
|
28
|
+
*
|
|
29
|
+
* Shared because there are two callers with the same problem and different
|
|
30
|
+
* sources for it: `pikku persona run` reads the inspector state at build time,
|
|
31
|
+
* and the scaffolded `runVirtualUser` RPC reads `metaService` at runtime. They
|
|
32
|
+
* must agree — a persona whose catalogue is narrower over RPC than on the CLI
|
|
33
|
+
* finds different things from the same seed, which is exactly the property a
|
|
34
|
+
* seed exists to give.
|
|
35
|
+
*
|
|
36
|
+
* Nothing here is authored for the virtual user's benefit: the function meta is
|
|
37
|
+
* the catalogue, the scenario meta is the intents, and the role definitions are
|
|
38
|
+
* what turn a persona's declared roles into the scopes a function checks.
|
|
39
|
+
*/
|
|
40
|
+
export declare const prepareVirtualUserRun: (input: {
|
|
41
|
+
persona: {
|
|
42
|
+
roles?: readonly string[];
|
|
43
|
+
};
|
|
44
|
+
functionsMeta: FunctionsMeta;
|
|
45
|
+
schemas?: SchemaMap;
|
|
46
|
+
workflowsMeta?: WorkflowsMeta;
|
|
47
|
+
/**
|
|
48
|
+
* Either shape: the inspector holds an array, `metaService` hands back the
|
|
49
|
+
* same definitions keyed by name. Accepting both is what lets the CLI and the
|
|
50
|
+
* scaffolded RPC share this.
|
|
51
|
+
*/
|
|
52
|
+
systemRoles?: SystemRoleDefinitions | SystemRoleDefinitionsMeta;
|
|
53
|
+
agentsMeta?: Readonly<Record<string, AgentReachability>>;
|
|
54
|
+
}) => VirtualUserPreparation;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { flattenSystemRoleDefinitions } from '../role/validate-role-definitions.js';
|
|
2
|
+
import { deriveCatalogue, deriveIntents, } from './virtual-user-derive.js';
|
|
3
|
+
import { reachableAgents, } from './virtual-user-agents.js';
|
|
4
|
+
/**
|
|
5
|
+
* The scopes a persona holds, resolved through its roles.
|
|
6
|
+
*
|
|
7
|
+
* Roles are the only thing a persona declares; scopes are what a function
|
|
8
|
+
* checks. Narrowing a virtual user's catalogue needs the second, so the
|
|
9
|
+
* expansion happens once, here, against the same `defineSystemRole` definitions
|
|
10
|
+
* the seed grants from.
|
|
11
|
+
*/
|
|
12
|
+
export const personaScopes = (persona, roleScopes) => {
|
|
13
|
+
const scopes = new Set();
|
|
14
|
+
for (const role of persona.roles ?? []) {
|
|
15
|
+
for (const scope of roleScopes[role] ?? []) {
|
|
16
|
+
scopes.add(scope);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return [...scopes].sort();
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Derive what a virtual user needs from what the project already generates.
|
|
23
|
+
*
|
|
24
|
+
* Shared because there are two callers with the same problem and different
|
|
25
|
+
* sources for it: `pikku persona run` reads the inspector state at build time,
|
|
26
|
+
* and the scaffolded `runVirtualUser` RPC reads `metaService` at runtime. They
|
|
27
|
+
* must agree — a persona whose catalogue is narrower over RPC than on the CLI
|
|
28
|
+
* finds different things from the same seed, which is exactly the property a
|
|
29
|
+
* seed exists to give.
|
|
30
|
+
*
|
|
31
|
+
* Nothing here is authored for the virtual user's benefit: the function meta is
|
|
32
|
+
* the catalogue, the scenario meta is the intents, and the role definitions are
|
|
33
|
+
* what turn a persona's declared roles into the scopes a function checks.
|
|
34
|
+
*/
|
|
35
|
+
export const prepareVirtualUserRun = (input) => {
|
|
36
|
+
const catalogue = deriveCatalogue(input.functionsMeta, input.schemas ?? {});
|
|
37
|
+
const intents = deriveIntents(input.workflowsMeta ?? {}, input.functionsMeta);
|
|
38
|
+
const declared = input.systemRoles ?? [];
|
|
39
|
+
const roleScopes = {};
|
|
40
|
+
for (const role of flattenSystemRoleDefinitions(Array.isArray(declared) ? declared : Object.values(declared))) {
|
|
41
|
+
roleScopes[role.name] = role.scopes;
|
|
42
|
+
}
|
|
43
|
+
const scopes = personaScopes(input.persona, roleScopes);
|
|
44
|
+
// Gated by the same scopes as the RPCs, because an agent is reached rather
|
|
45
|
+
// than declared: `CoreAIAgent.scopes` is checked against the session, so a
|
|
46
|
+
// persona finds the specialists its roles unlock and no others.
|
|
47
|
+
const agents = reachableAgents(input.agentsMeta ?? {}, scopes);
|
|
48
|
+
return { catalogue, intents, scopes, agents };
|
|
49
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { VirtualUserDisposition, VirtualUserFinding, VirtualUserTally } from './virtual-user.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* One recorded run: who ran, what they were told, and what came back.
|
|
4
|
+
*
|
|
5
|
+
* A run is dispatched and answered for later, so the record is created before
|
|
6
|
+
* the work starts and is the thing the returned `runId` addresses.
|
|
7
|
+
*
|
|
8
|
+
* This record is a run's ONLY trace. A virtual user is not a workflow — it
|
|
9
|
+
* explores, so no two attempts take the same steps and there is nothing to
|
|
10
|
+
* replay — and it is not queued either, because the record already carries the
|
|
11
|
+
* progress a queue would only be holding on the way here.
|
|
12
|
+
*
|
|
13
|
+
* The cost of that is the one thing to know when reading `status`: a restart
|
|
14
|
+
* mid-run leaves a record at `running` with nothing left to finish it. A run
|
|
15
|
+
* older than its budget window and still `running` is dead, not working.
|
|
16
|
+
*/
|
|
17
|
+
export interface VirtualUserRunRecord {
|
|
18
|
+
runId: string;
|
|
19
|
+
persona: string;
|
|
20
|
+
disposition: VirtualUserDisposition;
|
|
21
|
+
/** What makes a run replayable at all — a finding without it is an anecdote. */
|
|
22
|
+
seed: number;
|
|
23
|
+
/**
|
|
24
|
+
* `running` until the engine returns. Not derived from `finishedAt` being
|
|
25
|
+
* unset: a crashed run has no finish time either, and the two are not the
|
|
26
|
+
* same result.
|
|
27
|
+
*/
|
|
28
|
+
status: 'running' | 'completed' | 'failed';
|
|
29
|
+
/** The caller's situational goals, run alongside the derived intents. */
|
|
30
|
+
goals: string[];
|
|
31
|
+
/**
|
|
32
|
+
* Ids and slugs the user carried in, and whatever it learned on the way out.
|
|
33
|
+
* Kept because a finding only reproduces alongside the notes that produced it.
|
|
34
|
+
*/
|
|
35
|
+
memory: Record<string, string>;
|
|
36
|
+
findings: VirtualUserFinding[];
|
|
37
|
+
tally: VirtualUserTally | null;
|
|
38
|
+
/** Which budget or stopping rule ended the run. */
|
|
39
|
+
stoppedBy: string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Why the run itself failed, as opposed to what it found. A run that could
|
|
42
|
+
* not start has no findings and is not a clean empty result.
|
|
43
|
+
*/
|
|
44
|
+
error: string | null;
|
|
45
|
+
/** The session that started it, where the host tracks one. */
|
|
46
|
+
startedBy: string | null;
|
|
47
|
+
createdAt: Date;
|
|
48
|
+
finishedAt: Date | null;
|
|
49
|
+
}
|
|
50
|
+
/** What a run is created with — everything else is filled in by the outcome. */
|
|
51
|
+
export interface VirtualUserRunStart {
|
|
52
|
+
persona: string;
|
|
53
|
+
disposition: VirtualUserDisposition;
|
|
54
|
+
seed: number;
|
|
55
|
+
goals?: readonly string[];
|
|
56
|
+
memory?: Record<string, string>;
|
|
57
|
+
startedBy?: string | null;
|
|
58
|
+
}
|
|
59
|
+
/** The outcome of a run that reached the end of its budget without throwing. */
|
|
60
|
+
export interface VirtualUserRunOutcome {
|
|
61
|
+
findings: readonly VirtualUserFinding[];
|
|
62
|
+
tally: VirtualUserTally;
|
|
63
|
+
memory: Record<string, string>;
|
|
64
|
+
stoppedBy: string | null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Where runs are kept. Declared here rather than in a database package so the
|
|
68
|
+
* scaffolded RPCs depend on the shape and not on kysely — `@pikku/kysely` ships
|
|
69
|
+
* one implementation, and an app with its own store satisfies this instead.
|
|
70
|
+
*
|
|
71
|
+
* SECURITY: findings from an `adversarial` run are working exploits carrying
|
|
72
|
+
* live ids. An implementation is a privileged store; the scaffold gates every
|
|
73
|
+
* read behind a scope for that reason, and a host exposing these records more
|
|
74
|
+
* widely is publishing its own exploits.
|
|
75
|
+
*/
|
|
76
|
+
export interface VirtualUserRunStore {
|
|
77
|
+
/** Records a run as `running` and returns its id. */
|
|
78
|
+
start(run: VirtualUserRunStart): Promise<string>;
|
|
79
|
+
/** Marks a run `completed` and stores what it found. */
|
|
80
|
+
complete(runId: string, outcome: VirtualUserRunOutcome): Promise<void>;
|
|
81
|
+
/** Marks a run `failed`. The run itself broke; it has no findings. */
|
|
82
|
+
fail(runId: string, error: string): Promise<void>;
|
|
83
|
+
get(runId: string): Promise<VirtualUserRunRecord | null>;
|
|
84
|
+
/** Newest first. `persona` narrows to one persona's history. */
|
|
85
|
+
list(options?: {
|
|
86
|
+
persona?: string;
|
|
87
|
+
limit?: number;
|
|
88
|
+
offset?: number;
|
|
89
|
+
}): Promise<VirtualUserRunRecord[]>;
|
|
90
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -62,7 +62,7 @@ export type WorkflowWireDoRPC = <TOutput = any, TInput = any>(stepName: string,
|
|
|
62
62
|
*/
|
|
63
63
|
export type WorkflowWireDoInline = <T>(stepName: string, fn: () => Promise<T> | T, options?: WorkflowStepOptions) => Promise<T>;
|
|
64
64
|
/**
|
|
65
|
-
* Type signature for scenario.
|
|
65
|
+
* Type signature for scenario.given/when/then - used by inspector.
|
|
66
66
|
*
|
|
67
67
|
* Deliberately mirrors WorkflowWireDoRPC's shape: the target is a string, not
|
|
68
68
|
* an imported symbol, so the extractor reads it as a literal.
|
|
@@ -181,7 +181,7 @@ export interface RpcStepMeta {
|
|
|
181
181
|
expectEventually?: boolean;
|
|
182
182
|
}
|
|
183
183
|
/**
|
|
184
|
-
* Scenario step metadata — a call to `scenario.
|
|
184
|
+
* Scenario step metadata — a call to `scenario.given/when/then`.
|
|
185
185
|
*
|
|
186
186
|
* Distinct from RpcStepMeta on purpose: a step runs locally through
|
|
187
187
|
* runPikkuFunc and must never be treated as dispatchable on the queue/replay
|
|
@@ -194,7 +194,7 @@ export interface ScenarioStepMeta {
|
|
|
194
194
|
stepName: string;
|
|
195
195
|
/** Registered name of the step function being run */
|
|
196
196
|
stepFunc: string;
|
|
197
|
-
/** Which keyword the reporter prefixes —
|
|
197
|
+
/** Which keyword the reporter prefixes — Given, When or Then */
|
|
198
198
|
phase: ScenarioStepPhase;
|
|
199
199
|
/** Output variable name (if assigned) */
|
|
200
200
|
outputVar?: string;
|
|
@@ -484,18 +484,22 @@ export interface PikkuScenarioWire extends PikkuWorkflowWire {
|
|
|
484
484
|
/** Stub-assertion step: asserts `service.method` was called on the target server */
|
|
485
485
|
expectService: (stepName: string, serviceMethod: string, options?: WorkflowExpectServiceOptions) => Promise<void>;
|
|
486
486
|
/**
|
|
487
|
-
* Run a registered scenario step
|
|
488
|
-
*
|
|
489
|
-
*
|
|
487
|
+
* Run a registered scenario step, as the setup the scenario starts from.
|
|
488
|
+
*
|
|
489
|
+
* Shaped exactly like `do`'s RPC form — `(stepName, target, data, options)` —
|
|
490
|
+
* so the inspector reads the target as a string literal rather than resolving
|
|
491
|
+
* an imported symbol.
|
|
490
492
|
*
|
|
491
493
|
* The generated `TypedScenario` narrows these over `FlattenedScenarioStepMap`.
|
|
492
494
|
*/
|
|
493
|
-
step(stepName: string, stepFunc: string, data?: any, options?: ScenarioStepOptions): Promise<any>;
|
|
494
|
-
/** `step` with a "Given" prefix in the rendered prose */
|
|
495
495
|
given(stepName: string, stepFunc: string, data?: any, options?: ScenarioStepOptions): Promise<any>;
|
|
496
|
-
/** `
|
|
496
|
+
/** `when`: the same call as `given`, rendered as the action under test */
|
|
497
497
|
when(stepName: string, stepFunc: string, data?: any, options?: ScenarioStepOptions): Promise<any>;
|
|
498
|
-
/**
|
|
498
|
+
/**
|
|
499
|
+
* `then`: a claim about what the action left behind. Unlike `given`/`when`
|
|
500
|
+
* this is not prose alone — the step's bindings become witnesses, so every
|
|
501
|
+
* declared surface is observed and the observations must agree.
|
|
502
|
+
*/
|
|
499
503
|
then(stepName: string, stepFunc: string, data?: any, options?: ScenarioStepOptions): Promise<any>;
|
|
500
504
|
runScheduledTask: (name: string) => Promise<unknown>;
|
|
501
505
|
}
|
|
@@ -452,12 +452,11 @@ export class PikkuScenarioService {
|
|
|
452
452
|
}, options);
|
|
453
453
|
},
|
|
454
454
|
// Scenario steps: a named `pikkuScenarioStep` run as one durable step.
|
|
455
|
-
// `given`/`when
|
|
455
|
+
// `given`/`when` are sugar for each other, differing only in the
|
|
456
456
|
// prose a reporter renders. `then` is not: the phase is what decides
|
|
457
457
|
// whether the step's bindings are alternatives or witnesses, so the same
|
|
458
458
|
// step function called two ways runs differently. See
|
|
459
459
|
// {@link resolveScenarioSurfaces}.
|
|
460
|
-
step: (stepName, stepFunc, data, options) => this.scenarioStep('step', scenarioStepContext(), stepName, stepFunc, data, options),
|
|
461
460
|
given: (stepName, stepFunc, data, options) => this.scenarioStep('given', scenarioStepContext(), stepName, stepFunc, data, options),
|
|
462
461
|
when: (stepName, stepFunc, data, options) => this.scenarioStep('when', scenarioStepContext(), stepName, stepFunc, data, options),
|
|
463
462
|
then: (stepName, stepFunc, data, options) => this.scenarioStep('then', scenarioStepContext(), stepName, stepFunc, data, options),
|
|
@@ -15,7 +15,7 @@ const formatValue = (value) => {
|
|
|
15
15
|
return String(value);
|
|
16
16
|
};
|
|
17
17
|
export const composeStepProse = ({ phase, description, template, input, actor, keywordWidth, }) => {
|
|
18
|
-
const keyword =
|
|
18
|
+
const keyword = capitalise(phase);
|
|
19
19
|
const subject = actor ? `the ${actor}` : '';
|
|
20
20
|
const rendered = template ? renderStepTemplate(template, input) : description;
|
|
21
21
|
const sentence = [subject, rendered].filter(Boolean).join(' ');
|