@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CoreServices, PikkuRawWire } from '../../types/core.types.js'
|
|
2
2
|
import { PikkuError, addError } from '../../errors/error-handler.js'
|
|
3
|
+
import { isSecretValue, type SecretValue } from '../../secret-value.js'
|
|
3
4
|
|
|
4
5
|
export type RemoteAddonAuthBinding =
|
|
5
6
|
| { credentialId: string }
|
|
@@ -8,7 +9,7 @@ export type RemoteAddonAuthBinding =
|
|
|
8
9
|
resolve: (
|
|
9
10
|
services: CoreServices,
|
|
10
11
|
wire: PikkuRawWire
|
|
11
|
-
) => string | Promise<string
|
|
12
|
+
) => string | SecretValue<string> | Promise<string | SecretValue<string>>
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export class RemoteAddonAuthError extends PikkuError {
|
|
@@ -49,9 +50,13 @@ export async function resolveRemoteAddonToken(
|
|
|
49
50
|
token = await auth.resolve(services, wire)
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
// The wire is where a secret is meant to end up, so unwrap it here rather
|
|
54
|
+
// than at each branch — `resolve` and `getCredential` may hand one back too.
|
|
55
|
+
const resolved = isSecretValue(token) ? token.reveal() : token
|
|
56
|
+
|
|
57
|
+
if (resolved === null || resolved === undefined || resolved === '') {
|
|
53
58
|
throw new RemoteAddonAuthError(namespace, 'resolved token was empty')
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
return String(
|
|
61
|
+
return String(resolved)
|
|
57
62
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { RemoteAddonAuthError } from './remote-addon-auth.js'
|
|
14
14
|
import { wireAddon } from './wire-addon.js'
|
|
15
15
|
import { wireRemoteAddon } from './wire-remote-addon.js'
|
|
16
|
+
import { createSecretValue } from '../../secret-value.js'
|
|
16
17
|
|
|
17
18
|
const createLogger = () => ({
|
|
18
19
|
debug: () => {},
|
|
@@ -690,7 +691,7 @@ describe('ContextAwareRPCService.remote', () => {
|
|
|
690
691
|
|
|
691
692
|
describe('multi-instance addons', () => {
|
|
692
693
|
const createSecretService = () => ({
|
|
693
|
-
getSecret: async (key: string) => `secret:${key}
|
|
694
|
+
getSecret: async (key: string) => createSecretValue(`secret:${key}`),
|
|
694
695
|
hasSecret: async () => true,
|
|
695
696
|
setSecret: async () => {},
|
|
696
697
|
deleteSecret: async () => {},
|
|
@@ -701,7 +702,7 @@ describe('multi-instance addons', () => {
|
|
|
701
702
|
const createdServices: any[] = []
|
|
702
703
|
pikkuState('@addon/slack', 'package', 'factories', {
|
|
703
704
|
createSingletonServices: async (_config: any, parent: any) => {
|
|
704
|
-
const token = await parent.secrets.getSecret('slackToken')
|
|
705
|
+
const token = (await parent.secrets.getSecret('slackToken')).reveal()
|
|
705
706
|
const svc = { logger: createLogger(), token }
|
|
706
707
|
createdServices.push(svc)
|
|
707
708
|
return svc
|
|
@@ -748,7 +749,7 @@ describe('multi-instance addons', () => {
|
|
|
748
749
|
factoryCalls++
|
|
749
750
|
return {
|
|
750
751
|
logger: createLogger(),
|
|
751
|
-
token: await parent.secrets.getSecret('slackToken'),
|
|
752
|
+
token: (await parent.secrets.getSecret('slackToken')).reveal(),
|
|
752
753
|
}
|
|
753
754
|
},
|
|
754
755
|
} as never)
|
|
@@ -854,7 +855,8 @@ describe('wireRemoteAddon dispatch', () => {
|
|
|
854
855
|
|
|
855
856
|
const secretService = () =>
|
|
856
857
|
({
|
|
857
|
-
getSecret: async (key: string) =>
|
|
858
|
+
getSecret: async (key: string) =>
|
|
859
|
+
createSecretValue(`secret-value-for-${key}`),
|
|
858
860
|
getSecrets: async () => ({}),
|
|
859
861
|
hasSecret: async () => true,
|
|
860
862
|
setSecret: async () => {},
|
|
@@ -31,12 +31,24 @@ export type {
|
|
|
31
31
|
VirtualUserTally,
|
|
32
32
|
VirtualUserTarget,
|
|
33
33
|
} from './virtual-user.types.js'
|
|
34
|
+
export { PRODUCTION_DISPOSITION } from './virtual-user.types.js'
|
|
34
35
|
export {
|
|
35
36
|
runVirtualUser,
|
|
36
37
|
rememberIds,
|
|
37
38
|
type RunVirtualUserParams,
|
|
38
39
|
type VirtualUserCallContext,
|
|
39
40
|
} from './run-virtual-user.js'
|
|
41
|
+
export {
|
|
42
|
+
personaScopes,
|
|
43
|
+
prepareVirtualUserRun,
|
|
44
|
+
type VirtualUserPreparation,
|
|
45
|
+
} from './prepare-virtual-user-run.js'
|
|
46
|
+
export type {
|
|
47
|
+
VirtualUserRunOutcome,
|
|
48
|
+
VirtualUserRunRecord,
|
|
49
|
+
VirtualUserRunStart,
|
|
50
|
+
VirtualUserRunStore,
|
|
51
|
+
} from './virtual-user-run-store.js'
|
|
40
52
|
export {
|
|
41
53
|
DISPOSITIONS,
|
|
42
54
|
dispositionProfile,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {
|
|
4
|
+
personaScopes,
|
|
5
|
+
prepareVirtualUserRun,
|
|
6
|
+
} from './prepare-virtual-user-run.js'
|
|
7
|
+
|
|
8
|
+
const ROLE_DEFINITIONS = [
|
|
9
|
+
{ name: 'editor', scopes: ['docs:write', 'docs:read'] },
|
|
10
|
+
{ name: 'viewer', scopes: ['docs:read'] },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const FUNCTIONS_META = {
|
|
14
|
+
listDocs: { pikkuFuncName: 'listDocs', services: [], expose: true },
|
|
15
|
+
deleteDoc: { pikkuFuncName: 'deleteDoc', services: [], expose: true },
|
|
16
|
+
} as any
|
|
17
|
+
|
|
18
|
+
describe('personaScopes', () => {
|
|
19
|
+
test('expands the roles a persona declares into the scopes functions check', () => {
|
|
20
|
+
const scopes = personaScopes(
|
|
21
|
+
{ roles: ['editor'] },
|
|
22
|
+
{
|
|
23
|
+
editor: ['docs:write', 'docs:read'],
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
assert.deepEqual(scopes, ['docs:read', 'docs:write'])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('a persona with no roles holds no scopes', () => {
|
|
30
|
+
assert.deepEqual(personaScopes({}, { editor: ['docs:write'] }), [])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// Two roles granting the same scope is ordinary, and the catalogue narrowing
|
|
34
|
+
// downstream compares scopes as a set.
|
|
35
|
+
test('de-duplicates overlapping roles', () => {
|
|
36
|
+
const scopes = personaScopes(
|
|
37
|
+
{ roles: ['editor', 'viewer'] },
|
|
38
|
+
{
|
|
39
|
+
editor: ['docs:write', 'docs:read'],
|
|
40
|
+
viewer: ['docs:read'],
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
assert.deepEqual(scopes, ['docs:read', 'docs:write'])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// An undeclared role granting everything would be the worst possible default.
|
|
47
|
+
test('an unknown role grants nothing', () => {
|
|
48
|
+
assert.deepEqual(personaScopes({ roles: ['ghost'] }, {}), [])
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('prepareVirtualUserRun', () => {
|
|
53
|
+
// The CLI reads the inspector state, which holds an array; the scaffolded RPC
|
|
54
|
+
// reads metaService, which hands the same definitions back keyed by name. Both
|
|
55
|
+
// callers must land on the same scopes or the same persona and seed explore a
|
|
56
|
+
// different API depending on how the run was started.
|
|
57
|
+
test('accepts role definitions as an array or keyed by name, identically', () => {
|
|
58
|
+
const asArray = prepareVirtualUserRun({
|
|
59
|
+
persona: { roles: ['editor'] },
|
|
60
|
+
functionsMeta: FUNCTIONS_META,
|
|
61
|
+
systemRoles: ROLE_DEFINITIONS as any,
|
|
62
|
+
})
|
|
63
|
+
const asRecord = prepareVirtualUserRun({
|
|
64
|
+
persona: { roles: ['editor'] },
|
|
65
|
+
functionsMeta: FUNCTIONS_META,
|
|
66
|
+
systemRoles: {
|
|
67
|
+
editor: ROLE_DEFINITIONS[0],
|
|
68
|
+
viewer: ROLE_DEFINITIONS[1],
|
|
69
|
+
} as any,
|
|
70
|
+
})
|
|
71
|
+
assert.deepEqual(asArray.scopes, asRecord.scopes)
|
|
72
|
+
assert.deepEqual(asArray.scopes, ['docs:read', 'docs:write'])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('derives the catalogue from the function meta', () => {
|
|
76
|
+
const { catalogue } = prepareVirtualUserRun({
|
|
77
|
+
persona: {},
|
|
78
|
+
functionsMeta: FUNCTIONS_META,
|
|
79
|
+
})
|
|
80
|
+
assert.deepEqual(catalogue.map((entry) => entry.name).sort(), [
|
|
81
|
+
'deleteDoc',
|
|
82
|
+
'listDocs',
|
|
83
|
+
])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// An agent is reached rather than declared: its scopes are checked against the
|
|
87
|
+
// session, so a persona must find the specialists its roles unlock and no
|
|
88
|
+
// others.
|
|
89
|
+
test('narrows agents to the ones the persona s scopes reach', () => {
|
|
90
|
+
const agentsMeta = {
|
|
91
|
+
helper: { scopes: [] },
|
|
92
|
+
auditor: { scopes: ['docs:audit'] },
|
|
93
|
+
} as any
|
|
94
|
+
|
|
95
|
+
const { agents } = prepareVirtualUserRun({
|
|
96
|
+
persona: { roles: ['viewer'] },
|
|
97
|
+
functionsMeta: FUNCTIONS_META,
|
|
98
|
+
systemRoles: ROLE_DEFINITIONS as any,
|
|
99
|
+
agentsMeta,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
agents.map((agent) => agent.name),
|
|
104
|
+
['helper']
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('a project with no scenarios simply has no intents', () => {
|
|
109
|
+
const { intents } = prepareVirtualUserRun({
|
|
110
|
+
persona: {},
|
|
111
|
+
functionsMeta: FUNCTIONS_META,
|
|
112
|
+
})
|
|
113
|
+
assert.deepEqual(intents, [])
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { FunctionsMeta } from '../../types/core.types.js'
|
|
2
|
+
import type { WorkflowsMeta } from '../workflow/workflow.types.js'
|
|
3
|
+
import { flattenSystemRoleDefinitions } from '../role/validate-role-definitions.js'
|
|
4
|
+
import type {
|
|
5
|
+
SystemRoleDefinitions,
|
|
6
|
+
SystemRoleDefinitionsMeta,
|
|
7
|
+
} from '../role/role.types.js'
|
|
8
|
+
import {
|
|
9
|
+
deriveCatalogue,
|
|
10
|
+
deriveIntents,
|
|
11
|
+
type SchemaMap,
|
|
12
|
+
} from './virtual-user-derive.js'
|
|
13
|
+
import {
|
|
14
|
+
reachableAgents,
|
|
15
|
+
type AgentReachability,
|
|
16
|
+
type ReachableAgent,
|
|
17
|
+
} from './virtual-user-agents.js'
|
|
18
|
+
import type { ApiCatalogueEntry, IntentSource } from './virtual-user.types.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The scopes a persona holds, resolved through its roles.
|
|
22
|
+
*
|
|
23
|
+
* Roles are the only thing a persona declares; scopes are what a function
|
|
24
|
+
* checks. Narrowing a virtual user's catalogue needs the second, so the
|
|
25
|
+
* expansion happens once, here, against the same `defineSystemRole` definitions
|
|
26
|
+
* the seed grants from.
|
|
27
|
+
*/
|
|
28
|
+
export const personaScopes = (
|
|
29
|
+
persona: { roles?: readonly string[] },
|
|
30
|
+
roleScopes: Record<string, readonly string[]>
|
|
31
|
+
): string[] => {
|
|
32
|
+
const scopes = new Set<string>()
|
|
33
|
+
for (const role of persona.roles ?? []) {
|
|
34
|
+
for (const scope of roleScopes[role] ?? []) {
|
|
35
|
+
scopes.add(scope)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return [...scopes].sort()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Everything a run needs that is derived rather than decided. */
|
|
42
|
+
export interface VirtualUserPreparation {
|
|
43
|
+
/** Every RPC the app exposes, narrowed to what this persona can reach. */
|
|
44
|
+
catalogue: ApiCatalogueEntry[]
|
|
45
|
+
intents: IntentSource[]
|
|
46
|
+
scopes: string[]
|
|
47
|
+
agents: ReachableAgent[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Derive what a virtual user needs from what the project already generates.
|
|
52
|
+
*
|
|
53
|
+
* Shared because there are two callers with the same problem and different
|
|
54
|
+
* sources for it: `pikku persona run` reads the inspector state at build time,
|
|
55
|
+
* and the scaffolded `runVirtualUser` RPC reads `metaService` at runtime. They
|
|
56
|
+
* must agree — a persona whose catalogue is narrower over RPC than on the CLI
|
|
57
|
+
* finds different things from the same seed, which is exactly the property a
|
|
58
|
+
* seed exists to give.
|
|
59
|
+
*
|
|
60
|
+
* Nothing here is authored for the virtual user's benefit: the function meta is
|
|
61
|
+
* the catalogue, the scenario meta is the intents, and the role definitions are
|
|
62
|
+
* what turn a persona's declared roles into the scopes a function checks.
|
|
63
|
+
*/
|
|
64
|
+
export const prepareVirtualUserRun = (input: {
|
|
65
|
+
persona: { roles?: readonly string[] }
|
|
66
|
+
functionsMeta: FunctionsMeta
|
|
67
|
+
schemas?: SchemaMap
|
|
68
|
+
workflowsMeta?: WorkflowsMeta
|
|
69
|
+
/**
|
|
70
|
+
* Either shape: the inspector holds an array, `metaService` hands back the
|
|
71
|
+
* same definitions keyed by name. Accepting both is what lets the CLI and the
|
|
72
|
+
* scaffolded RPC share this.
|
|
73
|
+
*/
|
|
74
|
+
systemRoles?: SystemRoleDefinitions | SystemRoleDefinitionsMeta
|
|
75
|
+
agentsMeta?: Readonly<Record<string, AgentReachability>>
|
|
76
|
+
}): VirtualUserPreparation => {
|
|
77
|
+
const catalogue = deriveCatalogue(input.functionsMeta, input.schemas ?? {})
|
|
78
|
+
const intents = deriveIntents(input.workflowsMeta ?? {}, input.functionsMeta)
|
|
79
|
+
|
|
80
|
+
const declared = input.systemRoles ?? []
|
|
81
|
+
const roleScopes: Record<string, string[]> = {}
|
|
82
|
+
for (const role of flattenSystemRoleDefinitions(
|
|
83
|
+
Array.isArray(declared) ? declared : Object.values(declared)
|
|
84
|
+
)) {
|
|
85
|
+
roleScopes[role.name] = role.scopes
|
|
86
|
+
}
|
|
87
|
+
const scopes = personaScopes(input.persona, roleScopes)
|
|
88
|
+
|
|
89
|
+
// Gated by the same scopes as the RPCs, because an agent is reached rather
|
|
90
|
+
// than declared: `CoreAIAgent.scopes` is checked against the session, so a
|
|
91
|
+
// persona finds the specialists its roles unlock and no others.
|
|
92
|
+
const agents = reachableAgents(input.agentsMeta ?? {}, scopes)
|
|
93
|
+
|
|
94
|
+
return { catalogue, intents, scopes, agents }
|
|
95
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
VirtualUserDisposition,
|
|
3
|
+
VirtualUserFinding,
|
|
4
|
+
VirtualUserTally,
|
|
5
|
+
} from './virtual-user.types.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One recorded run: who ran, what they were told, and what came back.
|
|
9
|
+
*
|
|
10
|
+
* A run is dispatched and answered for later, so the record is created before
|
|
11
|
+
* the work starts and is the thing the returned `runId` addresses.
|
|
12
|
+
*
|
|
13
|
+
* This record is a run's ONLY trace. A virtual user is not a workflow — it
|
|
14
|
+
* explores, so no two attempts take the same steps and there is nothing to
|
|
15
|
+
* replay — and it is not queued either, because the record already carries the
|
|
16
|
+
* progress a queue would only be holding on the way here.
|
|
17
|
+
*
|
|
18
|
+
* The cost of that is the one thing to know when reading `status`: a restart
|
|
19
|
+
* mid-run leaves a record at `running` with nothing left to finish it. A run
|
|
20
|
+
* older than its budget window and still `running` is dead, not working.
|
|
21
|
+
*/
|
|
22
|
+
export interface VirtualUserRunRecord {
|
|
23
|
+
runId: string
|
|
24
|
+
persona: string
|
|
25
|
+
disposition: VirtualUserDisposition
|
|
26
|
+
/** What makes a run replayable at all — a finding without it is an anecdote. */
|
|
27
|
+
seed: number
|
|
28
|
+
/**
|
|
29
|
+
* `running` until the engine returns. Not derived from `finishedAt` being
|
|
30
|
+
* unset: a crashed run has no finish time either, and the two are not the
|
|
31
|
+
* same result.
|
|
32
|
+
*/
|
|
33
|
+
status: 'running' | 'completed' | 'failed'
|
|
34
|
+
/** The caller's situational goals, run alongside the derived intents. */
|
|
35
|
+
goals: string[]
|
|
36
|
+
/**
|
|
37
|
+
* Ids and slugs the user carried in, and whatever it learned on the way out.
|
|
38
|
+
* Kept because a finding only reproduces alongside the notes that produced it.
|
|
39
|
+
*/
|
|
40
|
+
memory: Record<string, string>
|
|
41
|
+
findings: VirtualUserFinding[]
|
|
42
|
+
tally: VirtualUserTally | null
|
|
43
|
+
/** Which budget or stopping rule ended the run. */
|
|
44
|
+
stoppedBy: string | null
|
|
45
|
+
/**
|
|
46
|
+
* Why the run itself failed, as opposed to what it found. A run that could
|
|
47
|
+
* not start has no findings and is not a clean empty result.
|
|
48
|
+
*/
|
|
49
|
+
error: string | null
|
|
50
|
+
/** The session that started it, where the host tracks one. */
|
|
51
|
+
startedBy: string | null
|
|
52
|
+
createdAt: Date
|
|
53
|
+
finishedAt: Date | null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What a run is created with — everything else is filled in by the outcome. */
|
|
57
|
+
export interface VirtualUserRunStart {
|
|
58
|
+
persona: string
|
|
59
|
+
disposition: VirtualUserDisposition
|
|
60
|
+
seed: number
|
|
61
|
+
goals?: readonly string[]
|
|
62
|
+
memory?: Record<string, string>
|
|
63
|
+
startedBy?: string | null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The outcome of a run that reached the end of its budget without throwing. */
|
|
67
|
+
export interface VirtualUserRunOutcome {
|
|
68
|
+
findings: readonly VirtualUserFinding[]
|
|
69
|
+
tally: VirtualUserTally
|
|
70
|
+
memory: Record<string, string>
|
|
71
|
+
stoppedBy: string | null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Where runs are kept. Declared here rather than in a database package so the
|
|
76
|
+
* scaffolded RPCs depend on the shape and not on kysely — `@pikku/kysely` ships
|
|
77
|
+
* one implementation, and an app with its own store satisfies this instead.
|
|
78
|
+
*
|
|
79
|
+
* SECURITY: findings from an `adversarial` run are working exploits carrying
|
|
80
|
+
* live ids. An implementation is a privileged store; the scaffold gates every
|
|
81
|
+
* read behind a scope for that reason, and a host exposing these records more
|
|
82
|
+
* widely is publishing its own exploits.
|
|
83
|
+
*/
|
|
84
|
+
export interface VirtualUserRunStore {
|
|
85
|
+
/** Records a run as `running` and returns its id. */
|
|
86
|
+
start(run: VirtualUserRunStart): Promise<string>
|
|
87
|
+
/** Marks a run `completed` and stores what it found. */
|
|
88
|
+
complete(runId: string, outcome: VirtualUserRunOutcome): Promise<void>
|
|
89
|
+
/** Marks a run `failed`. The run itself broke; it has no findings. */
|
|
90
|
+
fail(runId: string, error: string): Promise<void>
|
|
91
|
+
get(runId: string): Promise<VirtualUserRunRecord | null>
|
|
92
|
+
/** Newest first. `persona` narrows to one persona's history. */
|
|
93
|
+
list(options?: {
|
|
94
|
+
persona?: string
|
|
95
|
+
limit?: number
|
|
96
|
+
offset?: number
|
|
97
|
+
}): Promise<VirtualUserRunRecord[]>
|
|
98
|
+
}
|
|
@@ -85,7 +85,7 @@ export type WorkflowWireDoInline = <T>(
|
|
|
85
85
|
) => Promise<T>
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
* Type signature for scenario.
|
|
88
|
+
* Type signature for scenario.given/when/then - used by inspector.
|
|
89
89
|
*
|
|
90
90
|
* Deliberately mirrors WorkflowWireDoRPC's shape: the target is a string, not
|
|
91
91
|
* an imported symbol, so the extractor reads it as a literal.
|
|
@@ -199,7 +199,7 @@ export interface RpcStepMeta {
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
/**
|
|
202
|
-
* Scenario step metadata — a call to `scenario.
|
|
202
|
+
* Scenario step metadata — a call to `scenario.given/when/then`.
|
|
203
203
|
*
|
|
204
204
|
* Distinct from RpcStepMeta on purpose: a step runs locally through
|
|
205
205
|
* runPikkuFunc and must never be treated as dispatchable on the queue/replay
|
|
@@ -212,7 +212,7 @@ export interface ScenarioStepMeta {
|
|
|
212
212
|
stepName: string
|
|
213
213
|
/** Registered name of the step function being run */
|
|
214
214
|
stepFunc: string
|
|
215
|
-
/** Which keyword the reporter prefixes —
|
|
215
|
+
/** Which keyword the reporter prefixes — Given, When or Then */
|
|
216
216
|
phase: ScenarioStepPhase
|
|
217
217
|
/** Output variable name (if assigned) */
|
|
218
218
|
outputVar?: string
|
|
@@ -557,20 +557,14 @@ export interface PikkuScenarioWire extends PikkuWorkflowWire {
|
|
|
557
557
|
) => Promise<void>
|
|
558
558
|
|
|
559
559
|
/**
|
|
560
|
-
* Run a registered scenario step
|
|
561
|
-
*
|
|
562
|
-
*
|
|
560
|
+
* Run a registered scenario step, as the setup the scenario starts from.
|
|
561
|
+
*
|
|
562
|
+
* Shaped exactly like `do`'s RPC form — `(stepName, target, data, options)` —
|
|
563
|
+
* so the inspector reads the target as a string literal rather than resolving
|
|
564
|
+
* an imported symbol.
|
|
563
565
|
*
|
|
564
566
|
* The generated `TypedScenario` narrows these over `FlattenedScenarioStepMap`.
|
|
565
567
|
*/
|
|
566
|
-
step(
|
|
567
|
-
stepName: string,
|
|
568
|
-
stepFunc: string,
|
|
569
|
-
data?: any,
|
|
570
|
-
options?: ScenarioStepOptions
|
|
571
|
-
): Promise<any>
|
|
572
|
-
|
|
573
|
-
/** `step` with a "Given" prefix in the rendered prose */
|
|
574
568
|
given(
|
|
575
569
|
stepName: string,
|
|
576
570
|
stepFunc: string,
|
|
@@ -578,7 +572,7 @@ export interface PikkuScenarioWire extends PikkuWorkflowWire {
|
|
|
578
572
|
options?: ScenarioStepOptions
|
|
579
573
|
): Promise<any>
|
|
580
574
|
|
|
581
|
-
/** `
|
|
575
|
+
/** `when`: the same call as `given`, rendered as the action under test */
|
|
582
576
|
when(
|
|
583
577
|
stepName: string,
|
|
584
578
|
stepFunc: string,
|
|
@@ -586,7 +580,11 @@ export interface PikkuScenarioWire extends PikkuWorkflowWire {
|
|
|
586
580
|
options?: ScenarioStepOptions
|
|
587
581
|
): Promise<any>
|
|
588
582
|
|
|
589
|
-
/**
|
|
583
|
+
/**
|
|
584
|
+
* `then`: a claim about what the action left behind. Unlike `given`/`when`
|
|
585
|
+
* this is not prose alone — the step's bindings become witnesses, so every
|
|
586
|
+
* declared surface is observed and the observations must agree.
|
|
587
|
+
*/
|
|
590
588
|
then(
|
|
591
589
|
stepName: string,
|
|
592
590
|
stepFunc: string,
|
|
@@ -649,20 +649,11 @@ export class PikkuScenarioService implements WorkflowRunExtension {
|
|
|
649
649
|
},
|
|
650
650
|
|
|
651
651
|
// Scenario steps: a named `pikkuScenarioStep` run as one durable step.
|
|
652
|
-
// `given`/`when
|
|
652
|
+
// `given`/`when` are sugar for each other, differing only in the
|
|
653
653
|
// prose a reporter renders. `then` is not: the phase is what decides
|
|
654
654
|
// whether the step's bindings are alternatives or witnesses, so the same
|
|
655
655
|
// step function called two ways runs differently. See
|
|
656
656
|
// {@link resolveScenarioSurfaces}.
|
|
657
|
-
step: (stepName, stepFunc, data, options) =>
|
|
658
|
-
this.scenarioStep(
|
|
659
|
-
'step',
|
|
660
|
-
scenarioStepContext(),
|
|
661
|
-
stepName,
|
|
662
|
-
stepFunc,
|
|
663
|
-
data,
|
|
664
|
-
options
|
|
665
|
-
),
|
|
666
657
|
given: (stepName, stepFunc, data, options) =>
|
|
667
658
|
this.scenarioStep(
|
|
668
659
|
'given',
|
|
@@ -110,14 +110,14 @@ describe('composeStepProse basics', () => {
|
|
|
110
110
|
)
|
|
111
111
|
})
|
|
112
112
|
|
|
113
|
-
test('
|
|
113
|
+
test('every phase renders its keyword', () => {
|
|
114
114
|
assert.equal(
|
|
115
115
|
composeStepProse({
|
|
116
|
-
phase: '
|
|
116
|
+
phase: 'when',
|
|
117
117
|
description: 'refreshes the dashboard',
|
|
118
118
|
actor: 'admin',
|
|
119
119
|
}),
|
|
120
|
-
'the admin refreshes the dashboard'
|
|
120
|
+
'When the admin refreshes the dashboard'
|
|
121
121
|
)
|
|
122
122
|
})
|
|
123
123
|
|
|
@@ -127,7 +127,6 @@ describe('composeStepProse basics', () => {
|
|
|
127
127
|
{ phase: 'given', description: 'buys an apple' },
|
|
128
128
|
{ phase: 'when', description: 'checks out' },
|
|
129
129
|
{ phase: 'then', description: 'sees a receipt' },
|
|
130
|
-
{ phase: 'step', description: 'waits' },
|
|
131
130
|
] as const
|
|
132
131
|
).map((step) =>
|
|
133
132
|
composeStepProse({ ...step, actor: 'shopper', keywordWidth: 5 })
|
|
@@ -137,7 +136,6 @@ describe('composeStepProse basics', () => {
|
|
|
137
136
|
'Given the shopper buys an apple',
|
|
138
137
|
'When the shopper checks out',
|
|
139
138
|
'Then the shopper sees a receipt',
|
|
140
|
-
' the shopper waits',
|
|
141
139
|
])
|
|
142
140
|
const columns = new Set(rendered.map((line) => line.indexOf('the shopper')))
|
|
143
141
|
assert.equal(columns.size, 1, 'every sentence starts in the same column')
|
|
@@ -145,8 +143,8 @@ describe('composeStepProse basics', () => {
|
|
|
145
143
|
|
|
146
144
|
test('prose degrades to just the description when nothing else is known', () => {
|
|
147
145
|
assert.equal(
|
|
148
|
-
composeStepProse({ phase: '
|
|
149
|
-
'does the thing'
|
|
146
|
+
composeStepProse({ phase: 'when', description: 'does the thing' }),
|
|
147
|
+
'When does the thing'
|
|
150
148
|
)
|
|
151
149
|
})
|
|
152
150
|
})
|
|
@@ -37,7 +37,7 @@ export const composeStepProse = ({
|
|
|
37
37
|
actor?: string
|
|
38
38
|
keywordWidth?: number
|
|
39
39
|
}): string => {
|
|
40
|
-
const keyword =
|
|
40
|
+
const keyword = capitalise(phase)
|
|
41
41
|
const subject = actor ? `the ${actor}` : ''
|
|
42
42
|
const rendered = template ? renderStepTemplate(template, input) : description
|
|
43
43
|
const sentence = [subject, rendered].filter(Boolean).join(' ')
|
|
@@ -155,7 +155,7 @@ const registerStep = (
|
|
|
155
155
|
} as any
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
describe('pikkuScenarioStep (scenario.
|
|
158
|
+
describe('pikkuScenarioStep (scenario.given/when/then)', () => {
|
|
159
159
|
beforeEach(() => resetPikkuState())
|
|
160
160
|
|
|
161
161
|
test('the step func is called with the phase, step identity and data on the wire', async () => {
|
|
@@ -202,12 +202,11 @@ describe('pikkuScenarioStep (scenario.step/given/when/then)', () => {
|
|
|
202
202
|
|
|
203
203
|
const runId = await setup(ws)
|
|
204
204
|
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
205
|
-
await wire.step('a', 'noop')
|
|
206
205
|
await wire.given('b', 'noop')
|
|
207
206
|
await wire.when('c', 'noop')
|
|
208
207
|
await wire.then('d', 'noop')
|
|
209
208
|
|
|
210
|
-
assert.deepEqual(phases, ['
|
|
209
|
+
assert.deepEqual(phases, ['given', 'when', 'then'])
|
|
211
210
|
})
|
|
212
211
|
|
|
213
212
|
test('the actor is handed to the step rather than used to dispatch it', async () => {
|
|
@@ -291,7 +290,7 @@ describe('pikkuScenarioStep (scenario.step/given/when/then)', () => {
|
|
|
291
290
|
|
|
292
291
|
const runId = await setup(ws)
|
|
293
292
|
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
294
|
-
const result = await wire.
|
|
293
|
+
const result = await wire.when('waits for the page', 'flaky', undefined, {
|
|
295
294
|
retries: 3,
|
|
296
295
|
retryDelay: 1,
|
|
297
296
|
})
|
|
@@ -509,7 +508,7 @@ describe('pikkuScenarioStep (scenario.step/given/when/then)', () => {
|
|
|
509
508
|
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
510
509
|
|
|
511
510
|
await assert.rejects(
|
|
512
|
-
(wire.
|
|
511
|
+
(wire.when as any)('a name', async () => 'inline'),
|
|
513
512
|
/string/i
|
|
514
513
|
)
|
|
515
514
|
})
|
|
@@ -4,15 +4,21 @@ import type { ScenarioPersona } from '../../services/personas-service.js'
|
|
|
4
4
|
* Scenario steps: named, typed units of scenario behaviour.
|
|
5
5
|
*
|
|
6
6
|
* A step's body is an ordinary pikku function, so it may drive a browser, call
|
|
7
|
-
* an RPC as its actor, or run a workflow. `given
|
|
8
|
-
*
|
|
7
|
+
* an RPC as its actor, or run a workflow. `given` and `when` differ only in the
|
|
8
|
+
* prose the reporter renders; `then` also changes what runs, because its
|
|
9
|
+
* bindings are witnesses — every declared surface is observed and the
|
|
10
|
+
* observations must agree. See {@link ScenarioSurfaceResolution}.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
|
-
* Which Gherkin-style keyword the reporter prefixes this step with.
|
|
13
|
-
*
|
|
14
|
+
* Which Gherkin-style keyword the reporter prefixes this step with.
|
|
15
|
+
*
|
|
16
|
+
* Every step takes one. A scenario is read by people deciding whether it
|
|
17
|
+
* describes the behaviour they wanted, and a step that says only what it does
|
|
18
|
+
* without saying whether it is setup, action or claim is the one nobody can
|
|
19
|
+
* check — which is also why PKU680 can tell a scenario that never asserts.
|
|
14
20
|
*/
|
|
15
|
-
export type ScenarioStepPhase = '
|
|
21
|
+
export type ScenarioStepPhase = 'given' | 'when' | 'then'
|
|
16
22
|
|
|
17
23
|
/**
|
|
18
24
|
* Who acts in a step.
|
|
@@ -51,7 +57,7 @@ export const SCENARIO_SURFACES: readonly ScenarioSurface[] = [
|
|
|
51
57
|
/**
|
|
52
58
|
* How a step's declared surfaces resolve for one run.
|
|
53
59
|
*
|
|
54
|
-
* `given`/`when
|
|
60
|
+
* `given`/`when` bindings are **alternatives** — clicking Buy and calling
|
|
55
61
|
* `createOrder` are two ways to cause one effect, so exactly one runs.
|
|
56
62
|
*
|
|
57
63
|
* `then` bindings are **witnesses** — "the order row says paid" and "the
|
|
@@ -86,7 +92,7 @@ export type ScenarioSurfaceResolution =
|
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
/**
|
|
89
|
-
* Options accepted by `scenario.
|
|
95
|
+
* Options accepted by `scenario.given/when/then`.
|
|
90
96
|
*
|
|
91
97
|
* Note the retry default differs from an ordinary workflow step: retrying a
|
|
92
98
|
* failed assertion is the wrong behaviour for a test primitive, so steps
|