@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.
Files changed (143) hide show
  1. package/CHANGELOG.md +378 -0
  2. package/dist/column-form.d.ts +32 -0
  3. package/dist/column-form.js +42 -0
  4. package/dist/crypto-utils.d.ts +15 -4
  5. package/dist/crypto-utils.js +18 -2
  6. package/dist/data-classification.d.ts +44 -0
  7. package/dist/function/functions.types.d.ts +23 -10
  8. package/dist/function/index.d.ts +1 -1
  9. package/dist/index.d.ts +6 -3
  10. package/dist/index.js +3 -1
  11. package/dist/middleware/auth-bearer.js +2 -1
  12. package/dist/middleware/remote-auth.js +1 -1
  13. package/dist/remote.js +1 -1
  14. package/dist/secret-value.d.ts +56 -0
  15. package/dist/secret-value.js +46 -0
  16. package/dist/services/audit-service.d.ts +74 -4
  17. package/dist/services/audit-service.js +7 -5
  18. package/dist/services/credential-wire-service.d.ts +5 -0
  19. package/dist/services/credential-wire-service.js +9 -1
  20. package/dist/services/email-service.d.ts +2 -1
  21. package/dist/services/index.d.ts +3 -3
  22. package/dist/services/index.js +1 -1
  23. package/dist/services/local-content-request-handler.d.ts +29 -0
  24. package/dist/services/local-content-request-handler.js +176 -0
  25. package/dist/services/local-secrets.d.ts +4 -3
  26. package/dist/services/local-secrets.js +7 -3
  27. package/dist/services/logger.d.ts +22 -5
  28. package/dist/services/queue-webhook-service.js +1 -1
  29. package/dist/services/scoped-secret-service.d.ts +4 -3
  30. package/dist/services/secret-service.d.ts +8 -3
  31. package/dist/services/typed-secret-service.d.ts +5 -4
  32. package/dist/services/webhook-service.d.ts +2 -1
  33. package/dist/testing/service-tests.js +6 -6
  34. package/dist/types/core.types.d.ts +25 -4
  35. package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
  36. package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
  37. package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
  38. package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
  39. package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
  40. package/dist/wirings/ai-agent/index.d.ts +1 -1
  41. package/dist/wirings/ai-agent/index.js +1 -1
  42. package/dist/wirings/ai-agent/voice-input.d.ts +20 -0
  43. package/dist/wirings/ai-agent/voice-input.js +44 -9
  44. package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
  45. package/dist/wirings/ai-agent/voice-output.js +10 -1
  46. package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
  47. package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
  48. package/dist/wirings/cli/channel/index.d.ts +1 -0
  49. package/dist/wirings/persona/define-personas.d.ts +4 -0
  50. package/dist/wirings/persona/define-personas.js +4 -0
  51. package/dist/wirings/persona/persona.types.d.ts +11 -0
  52. package/dist/wirings/queue/queue-identity.js +2 -1
  53. package/dist/wirings/queue/queue.types.d.ts +2 -1
  54. package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
  55. package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
  56. package/dist/wirings/rpc/remote-addon-auth.js +6 -2
  57. package/dist/wirings/virtual-user/index.d.ts +3 -0
  58. package/dist/wirings/virtual-user/index.js +2 -0
  59. package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
  60. package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
  61. package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
  62. package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
  63. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
  64. package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
  65. package/dist/wirings/workflow/scenario-prose.js +1 -1
  66. package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
  67. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  68. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
  69. package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
  70. package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
  71. package/knowledge/decisions/internals/index.md +4 -0
  72. package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
  73. package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
  74. package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
  75. package/package.json +3 -2
  76. package/src/column-form.test.ts +97 -0
  77. package/src/column-form.ts +58 -0
  78. package/src/crypto-utils.ts +25 -6
  79. package/src/data-classification.ts +44 -0
  80. package/src/function/functions.types.ts +47 -10
  81. package/src/function/index.ts +1 -0
  82. package/src/index.ts +24 -2
  83. package/src/middleware/auth-bearer.test.ts +3 -2
  84. package/src/middleware/auth-bearer.ts +2 -1
  85. package/src/middleware/remote-auth.test.ts +2 -1
  86. package/src/middleware/remote-auth.ts +1 -1
  87. package/src/remote.test.ts +2 -1
  88. package/src/remote.ts +1 -1
  89. package/src/secret-value.test.ts +204 -0
  90. package/src/secret-value.ts +111 -0
  91. package/src/services/audit-service.ts +87 -9
  92. package/src/services/credential-wire-service.ts +9 -1
  93. package/src/services/email-service.ts +3 -1
  94. package/src/services/index.ts +3 -3
  95. package/src/services/local-content-request-handler.test.ts +202 -0
  96. package/src/services/local-content-request-handler.ts +267 -0
  97. package/src/services/local-secrets.test.ts +20 -5
  98. package/src/services/local-secrets.ts +15 -7
  99. package/src/services/logger.ts +27 -7
  100. package/src/services/queue-webhook-service.test.ts +2 -1
  101. package/src/services/queue-webhook-service.ts +1 -1
  102. package/src/services/scoped-secret-service.ts +4 -3
  103. package/src/services/secret-service.ts +8 -3
  104. package/src/services/typed-secret-service.ts +11 -7
  105. package/src/services/webhook-service.ts +4 -1
  106. package/src/testing/service-tests.ts +6 -6
  107. package/src/types/core.types.ts +25 -4
  108. package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
  109. package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
  110. package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
  111. package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
  112. package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
  113. package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
  114. package/src/wirings/ai-agent/index.ts +2 -0
  115. package/src/wirings/ai-agent/voice-input.test.ts +65 -0
  116. package/src/wirings/ai-agent/voice-input.ts +48 -9
  117. package/src/wirings/ai-agent/voice-output.test.ts +91 -1
  118. package/src/wirings/ai-agent/voice-output.ts +28 -1
  119. package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
  120. package/src/wirings/cli/channel/index.ts +4 -0
  121. package/src/wirings/persona/define-personas.ts +4 -0
  122. package/src/wirings/persona/persona.types.ts +11 -0
  123. package/src/wirings/queue/queue-identity.test.ts +2 -1
  124. package/src/wirings/queue/queue-identity.ts +4 -1
  125. package/src/wirings/queue/queue.types.ts +6 -1
  126. package/src/wirings/queue/signed-queue-service.ts +2 -1
  127. package/src/wirings/rpc/remote-addon-auth.ts +8 -3
  128. package/src/wirings/rpc/rpc-runner.test.ts +6 -4
  129. package/src/wirings/virtual-user/index.ts +12 -0
  130. package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
  131. package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
  132. package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
  133. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
  134. package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
  135. package/src/wirings/workflow/scenario-prose.test.ts +5 -7
  136. package/src/wirings/workflow/scenario-prose.ts +1 -1
  137. package/src/wirings/workflow/scenario-service.test.ts +0 -1
  138. package/src/wirings/workflow/scenario-step.test.ts +4 -5
  139. package/src/wirings/workflow/scenario-step.types.ts +13 -7
  140. package/src/wirings/workflow/scenario-surface.test.ts +6 -5
  141. package/src/wirings/workflow/workflow.types.ts +7 -0
  142. package/tsconfig.tsbuildinfo +1 -1
  143. 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
- if (token === null || token === undefined || token === '') {
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(token)
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) => `secret-value-for-${key}`,
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.step/given/when/then - used by inspector.
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.step/given/when/then`.
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 — given/when/then, or none for `step` */
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. Shaped exactly like `do`'s RPC form
561
- * `(stepName, target, data, options)` — so the inspector reads the target as
562
- * a string literal rather than resolving an imported symbol.
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
- /** `step` with a "When" prefix in the rendered prose */
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
- /** `step` with a "Then" prefix in the rendered prose */
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`/`step` are sugar for each other, differing only in the
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('the neutral `step` phase adds no keyword', () => {
113
+ test('every phase renders its keyword', () => {
114
114
  assert.equal(
115
115
  composeStepProse({
116
- phase: 'step',
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: 'step', description: 'does the thing' }),
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 = phase === 'step' ? '' : capitalise(phase)
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(' ')
@@ -21,7 +21,6 @@ const SCENARIO_MEMBERS = [
21
21
  ]
22
22
 
23
23
  const SCENARIO_WIRE_MEMBERS = [
24
- 'step',
25
24
  'given',
26
25
  'when',
27
26
  'then',
@@ -155,7 +155,7 @@ const registerStep = (
155
155
  } as any
156
156
  }
157
157
 
158
- describe('pikkuScenarioStep (scenario.step/given/when/then)', () => {
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, ['step', 'given', 'when', 'then'])
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.step('waits for the page', 'flaky', undefined, {
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.step as any)('a name', async () => 'inline'),
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`/`when`/`then` are sugar over
8
- * `step` they only change the prose the reporter renders.
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. `step`
13
- * renders no prefix at all.
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 = 'step' | 'given' | 'when' | 'then'
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`/`step` bindings are **alternatives** — clicking Buy and calling
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.step/given/when/then`.
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