@pikku/core 0.12.74 → 0.12.78

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 (150) hide show
  1. package/CHANGELOG.md +397 -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/in-memory-workflow-service.d.ts +1 -0
  22. package/dist/services/in-memory-workflow-service.js +20 -0
  23. package/dist/services/index.d.ts +3 -3
  24. package/dist/services/index.js +1 -1
  25. package/dist/services/local-content-request-handler.d.ts +29 -0
  26. package/dist/services/local-content-request-handler.js +176 -0
  27. package/dist/services/local-secrets.d.ts +4 -3
  28. package/dist/services/local-secrets.js +7 -3
  29. package/dist/services/logger.d.ts +22 -5
  30. package/dist/services/queue-webhook-service.js +1 -1
  31. package/dist/services/scoped-secret-service.d.ts +4 -3
  32. package/dist/services/secret-service.d.ts +8 -3
  33. package/dist/services/typed-secret-service.d.ts +5 -4
  34. package/dist/services/webhook-service.d.ts +2 -1
  35. package/dist/testing/service-tests.js +6 -6
  36. package/dist/types/core.types.d.ts +25 -4
  37. package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
  38. package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
  39. package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
  40. package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
  41. package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
  42. package/dist/wirings/ai-agent/index.d.ts +1 -1
  43. package/dist/wirings/ai-agent/index.js +1 -1
  44. package/dist/wirings/ai-agent/voice-input.d.ts +20 -0
  45. package/dist/wirings/ai-agent/voice-input.js +44 -9
  46. package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
  47. package/dist/wirings/ai-agent/voice-output.js +10 -1
  48. package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
  49. package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
  50. package/dist/wirings/cli/channel/index.d.ts +1 -0
  51. package/dist/wirings/persona/define-personas.d.ts +4 -0
  52. package/dist/wirings/persona/define-personas.js +4 -0
  53. package/dist/wirings/persona/persona.types.d.ts +11 -0
  54. package/dist/wirings/queue/queue-identity.js +2 -1
  55. package/dist/wirings/queue/queue.types.d.ts +2 -1
  56. package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
  57. package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
  58. package/dist/wirings/rpc/remote-addon-auth.js +6 -2
  59. package/dist/wirings/virtual-user/index.d.ts +3 -0
  60. package/dist/wirings/virtual-user/index.js +2 -0
  61. package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
  62. package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
  63. package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
  64. package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
  65. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
  66. package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
  67. package/dist/wirings/workflow/pikku-workflow-service.d.ts +35 -0
  68. package/dist/wirings/workflow/pikku-workflow-service.js +51 -0
  69. package/dist/wirings/workflow/scenario-prose.js +1 -1
  70. package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
  71. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  72. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
  73. package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
  74. package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
  75. package/knowledge/decisions/internals/index.md +4 -0
  76. package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
  77. package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
  78. package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
  79. package/package.json +3 -2
  80. package/src/column-form.test.ts +97 -0
  81. package/src/column-form.ts +58 -0
  82. package/src/crypto-utils.ts +25 -6
  83. package/src/data-classification.ts +44 -0
  84. package/src/function/functions.types.ts +47 -10
  85. package/src/function/index.ts +1 -0
  86. package/src/index.ts +24 -2
  87. package/src/middleware/auth-bearer.test.ts +3 -2
  88. package/src/middleware/auth-bearer.ts +2 -1
  89. package/src/middleware/remote-auth.test.ts +2 -1
  90. package/src/middleware/remote-auth.ts +1 -1
  91. package/src/remote.test.ts +2 -1
  92. package/src/remote.ts +1 -1
  93. package/src/secret-value.test.ts +204 -0
  94. package/src/secret-value.ts +111 -0
  95. package/src/services/audit-service.ts +87 -9
  96. package/src/services/credential-wire-service.ts +9 -1
  97. package/src/services/email-service.ts +3 -1
  98. package/src/services/in-memory-workflow-service.ts +29 -0
  99. package/src/services/index.ts +3 -3
  100. package/src/services/local-content-request-handler.test.ts +202 -0
  101. package/src/services/local-content-request-handler.ts +267 -0
  102. package/src/services/local-secrets.test.ts +20 -5
  103. package/src/services/local-secrets.ts +15 -7
  104. package/src/services/logger.ts +27 -7
  105. package/src/services/queue-webhook-service.test.ts +2 -1
  106. package/src/services/queue-webhook-service.ts +1 -1
  107. package/src/services/scoped-secret-service.ts +4 -3
  108. package/src/services/secret-service.ts +8 -3
  109. package/src/services/typed-secret-service.ts +11 -7
  110. package/src/services/webhook-service.ts +4 -1
  111. package/src/testing/service-tests.ts +6 -6
  112. package/src/types/core.types.ts +25 -4
  113. package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
  114. package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
  115. package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
  116. package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
  117. package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
  118. package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
  119. package/src/wirings/ai-agent/index.ts +2 -0
  120. package/src/wirings/ai-agent/voice-input.test.ts +65 -0
  121. package/src/wirings/ai-agent/voice-input.ts +48 -9
  122. package/src/wirings/ai-agent/voice-output.test.ts +91 -1
  123. package/src/wirings/ai-agent/voice-output.ts +28 -1
  124. package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
  125. package/src/wirings/cli/channel/index.ts +4 -0
  126. package/src/wirings/persona/define-personas.ts +4 -0
  127. package/src/wirings/persona/persona.types.ts +11 -0
  128. package/src/wirings/queue/queue-identity.test.ts +2 -1
  129. package/src/wirings/queue/queue-identity.ts +4 -1
  130. package/src/wirings/queue/queue.types.ts +6 -1
  131. package/src/wirings/queue/signed-queue-service.ts +2 -1
  132. package/src/wirings/rpc/remote-addon-auth.ts +8 -3
  133. package/src/wirings/rpc/rpc-runner.test.ts +6 -4
  134. package/src/wirings/virtual-user/index.ts +12 -0
  135. package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
  136. package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
  137. package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
  138. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
  139. package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
  140. package/src/wirings/workflow/pikku-workflow-service.ts +67 -0
  141. package/src/wirings/workflow/scenario-prose.test.ts +5 -7
  142. package/src/wirings/workflow/scenario-prose.ts +1 -1
  143. package/src/wirings/workflow/scenario-service.test.ts +0 -1
  144. package/src/wirings/workflow/scenario-step.test.ts +4 -5
  145. package/src/wirings/workflow/scenario-step.types.ts +13 -7
  146. package/src/wirings/workflow/scenario-surface.test.ts +6 -5
  147. package/src/wirings/workflow/workflow-stalled-recovery.test.ts +106 -0
  148. package/src/wirings/workflow/workflow.types.ts +7 -0
  149. package/tsconfig.tsbuildinfo +1 -1
  150. package/tsconfig.type-tests.json +12 -0
@@ -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',
@@ -275,6 +275,12 @@ const WORKFLOW_TERMINAL_STATES: ReadonlySet<string> = new Set([
275
275
  'cancelled',
276
276
  ])
277
277
 
278
+ /** Idle window before a `running` run with nothing in flight is treated as stalled. */
279
+ const DEFAULT_STALLED_RUN_MS = 5 * 60_000
280
+
281
+ /** Runs re-driven per `recoverStalledRuns` call, so one sweep is bounded. */
282
+ const DEFAULT_STALLED_RUN_LIMIT = 100
283
+
278
284
  const WORKFLOW_POLL_MIN_MS = 10
279
285
 
280
286
  const WORKFLOW_POLL_FACTOR = 1.6
@@ -908,6 +914,67 @@ export abstract class PikkuWorkflowService implements WorkflowService {
908
914
  )
909
915
  }
910
916
 
917
+ /**
918
+ * Ids of runs that are stalled: still `running`, with no step in a state that
919
+ * something is expected to complete (`running`, `scheduled`, `suspended`),
920
+ * and no step activity since `before`.
921
+ *
922
+ * Returns nothing by default so a store that cannot express the query keeps
923
+ * working unchanged; a store that overrides it gains crash recovery through
924
+ * `recoverStalledRuns`.
925
+ */
926
+ protected async findStalledRunIds(
927
+ _before: Date,
928
+ _limit: number
929
+ ): Promise<string[]> {
930
+ return []
931
+ }
932
+
933
+ /**
934
+ * Re-drive runs whose next move was lost, and report which were resumed.
935
+ *
936
+ * Arming a step is two writes to two systems — the step row, then the queue
937
+ * or scheduler job — so a process that dies between them leaves a run that is
938
+ * `running` with nothing in flight. Nothing notices: the run parks on a step
939
+ * that will never complete and never error, so it neither finishes nor fails.
940
+ * (Seen on a `workflow.sleep()`: a deploy restart landed between the sleep
941
+ * step's insert and its timer, parking the run permanently.)
942
+ *
943
+ * Replay is the recovery — `resumeWorkflow` re-orchestrates from persisted
944
+ * step state, and every settled step is memoized, so resuming a run that was
945
+ * not actually stuck costs an orchestration pass and changes nothing. That
946
+ * idempotence is what makes an idle-time heuristic safe here; a run that is
947
+ * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
948
+ *
949
+ * This is not self-starting. Call it from a scheduled task at whatever
950
+ * interval suits the workload.
951
+ */
952
+ public async recoverStalledRuns(options?: {
953
+ stalledAfterMs?: number
954
+ limit?: number
955
+ }): Promise<{ resumed: string[] }> {
956
+ const before = new Date(
957
+ Date.now() - (options?.stalledAfterMs ?? DEFAULT_STALLED_RUN_MS)
958
+ )
959
+ const runIds = await this.findStalledRunIds(
960
+ before,
961
+ options?.limit ?? DEFAULT_STALLED_RUN_LIMIT
962
+ )
963
+ const resumed: string[] = []
964
+ for (const runId of runIds) {
965
+ try {
966
+ await this.resumeWorkflow(runId)
967
+ resumed.push(runId)
968
+ } catch (err) {
969
+ // One unresumable run must not stop the sweep from recovering the rest.
970
+ getSingletonServices()?.logger?.error(
971
+ `Failed to resume stalled workflow run ${runId}: ${err instanceof Error ? err.message : String(err)}`
972
+ )
973
+ }
974
+ }
975
+ return { resumed }
976
+ }
977
+
911
978
  protected resolveStepJobOptions(
912
979
  stepOptions?: WorkflowStepOptions
913
980
  ): JobOptions {
@@ -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
@@ -22,11 +22,12 @@ describe('action steps pick exactly one binding', () => {
22
22
  })
23
23
  })
24
24
 
25
- test('given and step resolve like when, not like then', () => {
26
- for (const phase of ['given', 'step'] as const) {
27
- const resolution = resolveScenarioSurfaces(phase, ALL, 'cli')
28
- assert.equal(resolution.kind, 'action')
29
- }
25
+ test('given resolves like when, not like then', () => {
26
+ assert.deepEqual(resolveScenarioSurfaces('given', ALL, 'cli'), {
27
+ kind: 'action',
28
+ surface: 'cli',
29
+ fellBack: false,
30
+ })
30
31
  })
31
32
 
32
33
  test('a default run never counts as a fallback', () => {
@@ -0,0 +1,106 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
5
+ import { pikkuState } from '../../pikku-state.js'
6
+
7
+ const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
8
+
9
+ /**
10
+ * `recoverStalledRuns` re-drives a run by putting it back on the orchestrator
11
+ * queue, so the queue is the observation point.
12
+ */
13
+ function trackResumes(): { runIds: string[] } {
14
+ const seen: string[] = []
15
+ pikkuState(null, 'package', 'singletonServices', {
16
+ queueService: {
17
+ add: async (_queue: string, data: { runId: string }) => {
18
+ seen.push(data.runId)
19
+ },
20
+ },
21
+ logger: silentLogger,
22
+ } as any)
23
+ return { runIds: seen }
24
+ }
25
+
26
+ /** Backdates every persisted timestamp so the run reads as idle. */
27
+ async function backdate(
28
+ ws: InMemoryWorkflowService,
29
+ runId: string,
30
+ ms: number
31
+ ): Promise<void> {
32
+ const past = new Date(Date.now() - ms)
33
+ const run = await ws.getRun(runId)
34
+ ;(run as any).updatedAt = past
35
+ for (const step of await ws.getRunHistory(runId)) {
36
+ ;(step as any).updatedAt = past
37
+ }
38
+ }
39
+
40
+ describe('stalled run recovery', () => {
41
+ test('resumes a running run left with a pending step and nothing in flight', async () => {
42
+ const resumes = trackResumes()
43
+ const ws = new InMemoryWorkflowService()
44
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
45
+ type: 'test',
46
+ })
47
+ // A sleep whose timer was never armed: the step row exists, nothing will
48
+ // ever complete it.
49
+ await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
50
+ await backdate(ws, runId, 10 * 60_000)
51
+
52
+ const { resumed } = await ws.recoverStalledRuns()
53
+
54
+ assert.deepEqual(resumed, [runId], 'the orphaned run is resumed')
55
+ assert.deepEqual(resumes.runIds, [runId], 'it is put back on the queue')
56
+ })
57
+
58
+ test('leaves a run alone while a step is still scheduled', async () => {
59
+ trackResumes()
60
+ const ws = new InMemoryWorkflowService()
61
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
62
+ type: 'test',
63
+ })
64
+ const step = await ws.insertStepState(runId, 'Wait 1h', null, {
65
+ duration: 3_600_000,
66
+ })
67
+ await ws.setStepScheduled(step.stepId)
68
+ await backdate(ws, runId, 10 * 60_000)
69
+
70
+ const { resumed } = await ws.recoverStalledRuns()
71
+
72
+ assert.deepEqual(
73
+ resumed,
74
+ [],
75
+ 'a legitimately sleeping run is not a stalled run'
76
+ )
77
+ })
78
+
79
+ test('leaves a run alone until it has actually gone idle', async () => {
80
+ trackResumes()
81
+ const ws = new InMemoryWorkflowService()
82
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
83
+ type: 'test',
84
+ })
85
+ await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
86
+
87
+ const { resumed } = await ws.recoverStalledRuns()
88
+
89
+ assert.deepEqual(resumed, [], 'a run that just moved is not swept')
90
+ })
91
+
92
+ test('leaves a finished run alone', async () => {
93
+ trackResumes()
94
+ const ws = new InMemoryWorkflowService()
95
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
96
+ type: 'test',
97
+ })
98
+ await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
99
+ await ws.updateRunStatus(runId, 'completed')
100
+ await backdate(ws, runId, 10 * 60_000)
101
+
102
+ const { resumed } = await ws.recoverStalledRuns()
103
+
104
+ assert.deepEqual(resumed, [], 'only running runs are swept')
105
+ })
106
+ })
@@ -302,6 +302,13 @@ export type WorkflowsMeta = Record<
302
302
  dsl?: boolean
303
303
  expose?: boolean
304
304
  scenario?: boolean
305
+ /**
306
+ * The flow asserts through an expectation helper — `expectService`,
307
+ * `expectError`, `expectEventually` — rather than a `then` step. Those are
308
+ * inline steps and carry no phase, so without this PKU680 reads a scenario
309
+ * whose only witness is a recorded service call as asserting nothing.
310
+ */
311
+ asserts?: boolean
305
312
  skip?: string
306
313
  actors?: string[]
307
314
  }