@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,90 @@
1
+ import type { VirtualUserDisposition, VirtualUserFinding, VirtualUserTally } from './virtual-user.types.js';
2
+ /**
3
+ * One recorded run: who ran, what they were told, and what came back.
4
+ *
5
+ * A run is dispatched and answered for later, so the record is created before
6
+ * the work starts and is the thing the returned `runId` addresses.
7
+ *
8
+ * This record is a run's ONLY trace. A virtual user is not a workflow — it
9
+ * explores, so no two attempts take the same steps and there is nothing to
10
+ * replay — and it is not queued either, because the record already carries the
11
+ * progress a queue would only be holding on the way here.
12
+ *
13
+ * The cost of that is the one thing to know when reading `status`: a restart
14
+ * mid-run leaves a record at `running` with nothing left to finish it. A run
15
+ * older than its budget window and still `running` is dead, not working.
16
+ */
17
+ export interface VirtualUserRunRecord {
18
+ runId: string;
19
+ persona: string;
20
+ disposition: VirtualUserDisposition;
21
+ /** What makes a run replayable at all — a finding without it is an anecdote. */
22
+ seed: number;
23
+ /**
24
+ * `running` until the engine returns. Not derived from `finishedAt` being
25
+ * unset: a crashed run has no finish time either, and the two are not the
26
+ * same result.
27
+ */
28
+ status: 'running' | 'completed' | 'failed';
29
+ /** The caller's situational goals, run alongside the derived intents. */
30
+ goals: string[];
31
+ /**
32
+ * Ids and slugs the user carried in, and whatever it learned on the way out.
33
+ * Kept because a finding only reproduces alongside the notes that produced it.
34
+ */
35
+ memory: Record<string, string>;
36
+ findings: VirtualUserFinding[];
37
+ tally: VirtualUserTally | null;
38
+ /** Which budget or stopping rule ended the run. */
39
+ stoppedBy: string | null;
40
+ /**
41
+ * Why the run itself failed, as opposed to what it found. A run that could
42
+ * not start has no findings and is not a clean empty result.
43
+ */
44
+ error: string | null;
45
+ /** The session that started it, where the host tracks one. */
46
+ startedBy: string | null;
47
+ createdAt: Date;
48
+ finishedAt: Date | null;
49
+ }
50
+ /** What a run is created with — everything else is filled in by the outcome. */
51
+ export interface VirtualUserRunStart {
52
+ persona: string;
53
+ disposition: VirtualUserDisposition;
54
+ seed: number;
55
+ goals?: readonly string[];
56
+ memory?: Record<string, string>;
57
+ startedBy?: string | null;
58
+ }
59
+ /** The outcome of a run that reached the end of its budget without throwing. */
60
+ export interface VirtualUserRunOutcome {
61
+ findings: readonly VirtualUserFinding[];
62
+ tally: VirtualUserTally;
63
+ memory: Record<string, string>;
64
+ stoppedBy: string | null;
65
+ }
66
+ /**
67
+ * Where runs are kept. Declared here rather than in a database package so the
68
+ * scaffolded RPCs depend on the shape and not on kysely — `@pikku/kysely` ships
69
+ * one implementation, and an app with its own store satisfies this instead.
70
+ *
71
+ * SECURITY: findings from an `adversarial` run are working exploits carrying
72
+ * live ids. An implementation is a privileged store; the scaffold gates every
73
+ * read behind a scope for that reason, and a host exposing these records more
74
+ * widely is publishing its own exploits.
75
+ */
76
+ export interface VirtualUserRunStore {
77
+ /** Records a run as `running` and returns its id. */
78
+ start(run: VirtualUserRunStart): Promise<string>;
79
+ /** Marks a run `completed` and stores what it found. */
80
+ complete(runId: string, outcome: VirtualUserRunOutcome): Promise<void>;
81
+ /** Marks a run `failed`. The run itself broke; it has no findings. */
82
+ fail(runId: string, error: string): Promise<void>;
83
+ get(runId: string): Promise<VirtualUserRunRecord | null>;
84
+ /** Newest first. `persona` narrows to one persona's history. */
85
+ list(options?: {
86
+ persona?: string;
87
+ limit?: number;
88
+ offset?: number;
89
+ }): Promise<VirtualUserRunRecord[]>;
90
+ }
@@ -62,7 +62,7 @@ export type WorkflowWireDoRPC = <TOutput = any, TInput = any>(stepName: string,
62
62
  */
63
63
  export type WorkflowWireDoInline = <T>(stepName: string, fn: () => Promise<T> | T, options?: WorkflowStepOptions) => Promise<T>;
64
64
  /**
65
- * Type signature for scenario.step/given/when/then - used by inspector.
65
+ * Type signature for scenario.given/when/then - used by inspector.
66
66
  *
67
67
  * Deliberately mirrors WorkflowWireDoRPC's shape: the target is a string, not
68
68
  * an imported symbol, so the extractor reads it as a literal.
@@ -181,7 +181,7 @@ export interface RpcStepMeta {
181
181
  expectEventually?: boolean;
182
182
  }
183
183
  /**
184
- * Scenario step metadata — a call to `scenario.step/given/when/then`.
184
+ * Scenario step metadata — a call to `scenario.given/when/then`.
185
185
  *
186
186
  * Distinct from RpcStepMeta on purpose: a step runs locally through
187
187
  * runPikkuFunc and must never be treated as dispatchable on the queue/replay
@@ -194,7 +194,7 @@ export interface ScenarioStepMeta {
194
194
  stepName: string;
195
195
  /** Registered name of the step function being run */
196
196
  stepFunc: string;
197
- /** Which keyword the reporter prefixes — given/when/then, or none for `step` */
197
+ /** Which keyword the reporter prefixes — Given, When or Then */
198
198
  phase: ScenarioStepPhase;
199
199
  /** Output variable name (if assigned) */
200
200
  outputVar?: string;
@@ -484,18 +484,22 @@ export interface PikkuScenarioWire extends PikkuWorkflowWire {
484
484
  /** Stub-assertion step: asserts `service.method` was called on the target server */
485
485
  expectService: (stepName: string, serviceMethod: string, options?: WorkflowExpectServiceOptions) => Promise<void>;
486
486
  /**
487
- * Run a registered scenario step. Shaped exactly like `do`'s RPC form
488
- * `(stepName, target, data, options)` — so the inspector reads the target as
489
- * a string literal rather than resolving an imported symbol.
487
+ * Run a registered scenario step, as the setup the scenario starts from.
488
+ *
489
+ * Shaped exactly like `do`'s RPC form `(stepName, target, data, options)` —
490
+ * so the inspector reads the target as a string literal rather than resolving
491
+ * an imported symbol.
490
492
  *
491
493
  * The generated `TypedScenario` narrows these over `FlattenedScenarioStepMap`.
492
494
  */
493
- step(stepName: string, stepFunc: string, data?: any, options?: ScenarioStepOptions): Promise<any>;
494
- /** `step` with a "Given" prefix in the rendered prose */
495
495
  given(stepName: string, stepFunc: string, data?: any, options?: ScenarioStepOptions): Promise<any>;
496
- /** `step` with a "When" prefix in the rendered prose */
496
+ /** `when`: the same call as `given`, rendered as the action under test */
497
497
  when(stepName: string, stepFunc: string, data?: any, options?: ScenarioStepOptions): Promise<any>;
498
- /** `step` with a "Then" prefix in the rendered prose */
498
+ /**
499
+ * `then`: a claim about what the action left behind. Unlike `given`/`when`
500
+ * this is not prose alone — the step's bindings become witnesses, so every
501
+ * declared surface is observed and the observations must agree.
502
+ */
499
503
  then(stepName: string, stepFunc: string, data?: any, options?: ScenarioStepOptions): Promise<any>;
500
504
  runScheduledTask: (name: string) => Promise<unknown>;
501
505
  }
@@ -452,12 +452,11 @@ export class PikkuScenarioService {
452
452
  }, options);
453
453
  },
454
454
  // Scenario steps: a named `pikkuScenarioStep` run as one durable step.
455
- // `given`/`when`/`step` are sugar for each other, differing only in the
455
+ // `given`/`when` are sugar for each other, differing only in the
456
456
  // prose a reporter renders. `then` is not: the phase is what decides
457
457
  // whether the step's bindings are alternatives or witnesses, so the same
458
458
  // step function called two ways runs differently. See
459
459
  // {@link resolveScenarioSurfaces}.
460
- step: (stepName, stepFunc, data, options) => this.scenarioStep('step', scenarioStepContext(), stepName, stepFunc, data, options),
461
460
  given: (stepName, stepFunc, data, options) => this.scenarioStep('given', scenarioStepContext(), stepName, stepFunc, data, options),
462
461
  when: (stepName, stepFunc, data, options) => this.scenarioStep('when', scenarioStepContext(), stepName, stepFunc, data, options),
463
462
  then: (stepName, stepFunc, data, options) => this.scenarioStep('then', scenarioStepContext(), stepName, stepFunc, data, options),
@@ -168,6 +168,41 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
168
168
  source: string;
169
169
  } | null>;
170
170
  resumeWorkflow(runId: string, workflowName?: string): Promise<void>;
171
+ /**
172
+ * Ids of runs that are stalled: still `running`, with no step in a state that
173
+ * something is expected to complete (`running`, `scheduled`, `suspended`),
174
+ * and no step activity since `before`.
175
+ *
176
+ * Returns nothing by default so a store that cannot express the query keeps
177
+ * working unchanged; a store that overrides it gains crash recovery through
178
+ * `recoverStalledRuns`.
179
+ */
180
+ protected findStalledRunIds(_before: Date, _limit: number): Promise<string[]>;
181
+ /**
182
+ * Re-drive runs whose next move was lost, and report which were resumed.
183
+ *
184
+ * Arming a step is two writes to two systems — the step row, then the queue
185
+ * or scheduler job — so a process that dies between them leaves a run that is
186
+ * `running` with nothing in flight. Nothing notices: the run parks on a step
187
+ * that will never complete and never error, so it neither finishes nor fails.
188
+ * (Seen on a `workflow.sleep()`: a deploy restart landed between the sleep
189
+ * step's insert and its timer, parking the run permanently.)
190
+ *
191
+ * Replay is the recovery — `resumeWorkflow` re-orchestrates from persisted
192
+ * step state, and every settled step is memoized, so resuming a run that was
193
+ * not actually stuck costs an orchestration pass and changes nothing. That
194
+ * idempotence is what makes an idle-time heuristic safe here; a run that is
195
+ * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
196
+ *
197
+ * This is not self-starting. Call it from a scheduled task at whatever
198
+ * interval suits the workload.
199
+ */
200
+ recoverStalledRuns(options?: {
201
+ stalledAfterMs?: number;
202
+ limit?: number;
203
+ }): Promise<{
204
+ resumed: string[];
205
+ }>;
171
206
  protected resolveStepJobOptions(stepOptions?: WorkflowStepOptions): JobOptions;
172
207
  queueStepWorker(runId: string, stepName: string, rpcName: string, data: any, stepOptions?: WorkflowStepOptions, fromStepName?: string): Promise<void>;
173
208
  executeWorkflowSleepCompleted(runId: string, stepId: string): Promise<void>;
@@ -142,6 +142,10 @@ const WORKFLOW_TERMINAL_STATES = new Set([
142
142
  'failed',
143
143
  'cancelled',
144
144
  ]);
145
+ /** Idle window before a `running` run with nothing in flight is treated as stalled. */
146
+ const DEFAULT_STALLED_RUN_MS = 5 * 60_000;
147
+ /** Runs re-driven per `recoverStalledRuns` call, so one sweep is bounded. */
148
+ const DEFAULT_STALLED_RUN_LIMIT = 100;
145
149
  const WORKFLOW_POLL_MIN_MS = 10;
146
150
  const WORKFLOW_POLL_FACTOR = 1.6;
147
151
  const WORKFLOW_CHILD_POLL_MAX_MS = 500;
@@ -435,6 +439,53 @@ export class PikkuWorkflowService {
435
439
  group: this.getJobGroup(workflowName),
436
440
  });
437
441
  }
442
+ /**
443
+ * Ids of runs that are stalled: still `running`, with no step in a state that
444
+ * something is expected to complete (`running`, `scheduled`, `suspended`),
445
+ * and no step activity since `before`.
446
+ *
447
+ * Returns nothing by default so a store that cannot express the query keeps
448
+ * working unchanged; a store that overrides it gains crash recovery through
449
+ * `recoverStalledRuns`.
450
+ */
451
+ async findStalledRunIds(_before, _limit) {
452
+ return [];
453
+ }
454
+ /**
455
+ * Re-drive runs whose next move was lost, and report which were resumed.
456
+ *
457
+ * Arming a step is two writes to two systems — the step row, then the queue
458
+ * or scheduler job — so a process that dies between them leaves a run that is
459
+ * `running` with nothing in flight. Nothing notices: the run parks on a step
460
+ * that will never complete and never error, so it neither finishes nor fails.
461
+ * (Seen on a `workflow.sleep()`: a deploy restart landed between the sleep
462
+ * step's insert and its timer, parking the run permanently.)
463
+ *
464
+ * Replay is the recovery — `resumeWorkflow` re-orchestrates from persisted
465
+ * step state, and every settled step is memoized, so resuming a run that was
466
+ * not actually stuck costs an orchestration pass and changes nothing. That
467
+ * idempotence is what makes an idle-time heuristic safe here; a run that is
468
+ * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
469
+ *
470
+ * This is not self-starting. Call it from a scheduled task at whatever
471
+ * interval suits the workload.
472
+ */
473
+ async recoverStalledRuns(options) {
474
+ const before = new Date(Date.now() - (options?.stalledAfterMs ?? DEFAULT_STALLED_RUN_MS));
475
+ const runIds = await this.findStalledRunIds(before, options?.limit ?? DEFAULT_STALLED_RUN_LIMIT);
476
+ const resumed = [];
477
+ for (const runId of runIds) {
478
+ try {
479
+ await this.resumeWorkflow(runId);
480
+ resumed.push(runId);
481
+ }
482
+ catch (err) {
483
+ // One unresumable run must not stop the sweep from recovering the rest.
484
+ getSingletonServices()?.logger?.error(`Failed to resume stalled workflow run ${runId}: ${err instanceof Error ? err.message : String(err)}`);
485
+ }
486
+ }
487
+ return { resumed };
488
+ }
438
489
  resolveStepJobOptions(stepOptions) {
439
490
  const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES;
440
491
  const retryDelay = stepOptions?.retryDelay;
@@ -15,7 +15,7 @@ const formatValue = (value) => {
15
15
  return String(value);
16
16
  };
17
17
  export const composeStepProse = ({ phase, description, template, input, actor, keywordWidth, }) => {
18
- const keyword = phase === 'step' ? '' : capitalise(phase);
18
+ const keyword = capitalise(phase);
19
19
  const subject = actor ? `the ${actor}` : '';
20
20
  const rendered = template ? renderStepTemplate(template, input) : description;
21
21
  const sentence = [subject, rendered].filter(Boolean).join(' ');
@@ -3,14 +3,20 @@ import type { ScenarioPersona } from '../../services/personas-service.js';
3
3
  * Scenario steps: named, typed units of scenario behaviour.
4
4
  *
5
5
  * A step's body is an ordinary pikku function, so it may drive a browser, call
6
- * an RPC as its actor, or run a workflow. `given`/`when`/`then` are sugar over
7
- * `step` they only change the prose the reporter renders.
6
+ * an RPC as its actor, or run a workflow. `given` and `when` differ only in the
7
+ * prose the reporter renders; `then` also changes what runs, because its
8
+ * bindings are witnesses — every declared surface is observed and the
9
+ * observations must agree. See {@link ScenarioSurfaceResolution}.
8
10
  */
9
11
  /**
10
- * Which Gherkin-style keyword the reporter prefixes this step with. `step`
11
- * renders no prefix at all.
12
+ * Which Gherkin-style keyword the reporter prefixes this step with.
13
+ *
14
+ * Every step takes one. A scenario is read by people deciding whether it
15
+ * describes the behaviour they wanted, and a step that says only what it does
16
+ * without saying whether it is setup, action or claim is the one nobody can
17
+ * check — which is also why PKU680 can tell a scenario that never asserts.
12
18
  */
13
- export type ScenarioStepPhase = 'step' | 'given' | 'when' | 'then';
19
+ export type ScenarioStepPhase = 'given' | 'when' | 'then';
14
20
  /**
15
21
  * Who acts in a step.
16
22
  *
@@ -41,7 +47,7 @@ export declare const SCENARIO_SURFACES: readonly ScenarioSurface[];
41
47
  /**
42
48
  * How a step's declared surfaces resolve for one run.
43
49
  *
44
- * `given`/`when`/`step` bindings are **alternatives** — clicking Buy and calling
50
+ * `given`/`when` bindings are **alternatives** — clicking Buy and calling
45
51
  * `createOrder` are two ways to cause one effect, so exactly one runs.
46
52
  *
47
53
  * `then` bindings are **witnesses** — "the order row says paid" and "the
@@ -73,7 +79,7 @@ export type ScenarioSurfaceResolution = {
73
79
  unwitnessed: boolean;
74
80
  };
75
81
  /**
76
- * Options accepted by `scenario.step/given/when/then`.
82
+ * Options accepted by `scenario.given/when/then`.
77
83
  *
78
84
  * Note the retry default differs from an ordinary workflow step: retrying a
79
85
  * failed assertion is the wrong behaviour for a test primitive, so steps
@@ -188,6 +188,13 @@ export type WorkflowsMeta = Record<string, CommonWireMeta & {
188
188
  dsl?: boolean;
189
189
  expose?: boolean;
190
190
  scenario?: boolean;
191
+ /**
192
+ * The flow asserts through an expectation helper — `expectService`,
193
+ * `expectError`, `expectEventually` — rather than a `then` step. Those are
194
+ * inline steps and carry no phase, so without this PKU680 reads a scenario
195
+ * whose only witness is a recorded service call as asserting nothing.
196
+ */
197
+ asserts?: boolean;
191
198
  skip?: string;
192
199
  actors?: string[];
193
200
  }>;
@@ -0,0 +1,48 @@
1
+ ---
2
+ type: decision
3
+ title: A virtual user run is not a workflow and not a queued job
4
+ description: runVirtualUser writes its record, dispatches the run without awaiting it, and returns the id — because an exploratory run has nothing to replay and the record already carries what a queue would be holding
5
+ tags: virtual-user, storage
6
+ ---
7
+
8
+ # A virtual user run is not a workflow and not a queued job
9
+
10
+ `runVirtualUser` — the RPC `scaffold.virtualUser` generates — does three things
11
+ in order: writes a `VirtualUserRunStore` record, dispatches
12
+ `executeVirtualUserRun` **without awaiting it**, and returns the `runId`. There
13
+ is no workflow, no queue, and no worker.
14
+
15
+ Both of the alternatives are the obvious ones, and both are wrong for this.
16
+
17
+ **A workflow** is a replayable step graph: its value is that a run can be
18
+ resumed at the step it died on, and that the same input reaches the same step.
19
+ A virtual user is the opposite by construction — it is an LLM deciding what to
20
+ try next, so no two attempts take the same steps, and there is no step to resume
21
+ *to*. Recording a run as a workflow puts entries in the workflow store that can
22
+ never be replayed, and gives every operator reading that store a row that lies
23
+ about what it is. The seed makes a run *reproducible* — run it again and it
24
+ explores the same way — which is a different property from resumable, and one
25
+ the record already carries.
26
+
27
+ **A queue** buys durability across a restart and a retry on failure. It costs a
28
+ broker dependency in every application that turns the scaffold on, plus a worker
29
+ whose progress cannot be read anyway: the run's state lives in the store, not in
30
+ the queue entry. The queue would be holding a copy of what the record already
31
+ has, on the way to the same place.
32
+
33
+ So the record is the run's only trace, and that is what `VirtualUserRunStore`
34
+ exists for. It is also why `fail()` is a method rather than an absence: a run
35
+ that crashed and a run that found nothing are different answers, and a record
36
+ left at `running` is neither.
37
+
38
+ The cost is real and is stated on the type: **a restart mid-run strands a record
39
+ at `running` with nothing left to finish it.** A run older than its budget
40
+ window and still `running` is dead, not working — that is a read-side rule, and
41
+ it is cheaper than the two dependencies avoided. Nothing retries; a stranded run
42
+ is started again, with its seed if the caller wants the same exploration.
43
+
44
+ **What this rules out:** dispatching the run through `startWorkflow`; a
45
+ scaffolded queue worker; awaiting the engine inside the request (a run takes
46
+ minutes and survives neither a rollout nor a proxy timeout); and inferring
47
+ `status` from `finishedAt` being unset, which cannot separate a crash from a run
48
+ still going.
@@ -0,0 +1,84 @@
1
+ ---
2
+ type: decision
3
+ title: A column's at-rest form is an axis of its own
4
+ description: How a value is stored is independent of how sensitive it is, so form carries a required nominal brand on writes while classification stays optional on reads
5
+ tags: core
6
+ ---
7
+
8
+ # A column's at-rest form is an axis of its own
9
+
10
+ `ColumnForm` in `packages/core/src/data-classification.ts` is a second,
11
+ independent annotation on a column: `plain | hashed | wrapped | sealed`. It
12
+ answers "how are these bytes held?", where `Classification` answers "may this
13
+ value leave the process?".
14
+
15
+ The two axes were one field before this, with `security: 'encrypted'` sitting
16
+ alongside `secret` as though they were alternatives. They are not, and the
17
+ conflation made the field unanswerable: a token hash and a live bearer token are
18
+ both `secret`, one must never be encrypted — the digest *is* the lookup key —
19
+ and the other must always be. Nothing in a single enum could tell them apart, so
20
+ nothing could check either.
21
+
22
+ ## Why `wrapped` and `sealed` rather than `encrypted`
23
+
24
+ Sealed values *are* encrypted, so an `encrypted` member sitting beside `sealed`
25
+ would be a supertype posing as a sibling, and every new column would be an
26
+ even-odds guess. What actually separates them is who can read the value back:
27
+ `wrapped` is symmetric and the application holds the key; `sealed` is asymmetric
28
+ and the application holds only the public half. Writing one where the other
29
+ belongs produces a row nobody can ever open, which is why the type system is
30
+ made to know the difference.
31
+
32
+ ## Why these brands are required when `Secret<T>` is optional
33
+
34
+ `WrappedValue`, `SealedValue` and `HashedValue` are `string & { readonly [sym]:
35
+ true }` with a `unique symbol` — nominal, and **required**, which
36
+ [the classification-brand decision](core-data-classification-brand-is-an-optional-property.md)
37
+ explicitly rules out for `Private`/`Pii`/`Secret`. That decision still stands and
38
+ this does not weaken it. It applies to a different side of a different set of
39
+ columns:
40
+
41
+ - `Secret<T>` brands **every** classified column's SELECT type. A required brand
42
+ there would break `where('email', '=', someString)` in every downstream
43
+ project.
44
+ - A form brands **only** the INSERT/UPDATE type, and only on the columns that
45
+ opt in by declaring a form. There is nothing to break, because a column
46
+ without a form generates exactly what it generated before.
47
+
48
+ The brands compose rather than compete: a wrapped secret column selects as
49
+ `Secret<WrappedValue>`, so the inspector's PKU910 check still finds
50
+ `__classification__`, while a row read back is already a `WrappedValue` and
51
+ flows into a rewrap or re-seal without a cast.
52
+
53
+ Each brand widens to `string`, so query operands, serialization and template
54
+ literals are unaffected. The constraint is on **construction**: the only way to
55
+ produce one is `envelopeEncrypt`/`envelopeRewrap`/`wrapDEK` (wrapped) or
56
+ `hashToken` (hashed), or the deliberately-named `unsafeAs*` assertions in
57
+ `column-form.ts` for the three cases a bare string legitimately arrives —
58
+ backfill migrations, test fixtures, and values sealed by another service.
59
+
60
+ ## What is deliberately NOT enforced
61
+
62
+ `envelopeDecrypt` and `unwrapDEK` take plain `string`, not the brand. Requiring
63
+ it would buy nothing — feeding in the wrong string already fails at the AEAD tag
64
+ — while forcing a cast into every path that reads ciphertext out of a row or off
65
+ the wire, which is where casts are least reviewable.
66
+
67
+ The brand proves *provenance*, not correctness. It cannot know a value was
68
+ wrapped under the right key, and making it know would mean phantom-typing key
69
+ ids per scope, which the multi-recipient path would fight constantly.
70
+
71
+ ## The plain-secret diagnostic
72
+
73
+ A `secret` column with no declared form raises **PKU483** as a warning, not an
74
+ error. Every project predating the axis has such columns, and failing their next
75
+ `db migrate` would be a breaking change for a diagnosis they have not had a
76
+ chance to act on. `pikku db --fail-on-warn` is how a project opts into the
77
+ ratchet. An explicit `form: 'plain'` silences it — that is the acknowledgement
78
+ that reading the row is *meant* to yield a usable credential.
79
+
80
+ **What this rules out:** collapsing `wrapped` and `sealed` back into one
81
+ `encrypted`; making the form brands optional (they would enforce nothing);
82
+ re-declaring them in generated schema files, since a local `unique symbol` is a
83
+ distinct nominal type and core's own ciphertext would not be assignable to the
84
+ column it belongs in — `db-codegen` imports them from `@pikku/core` instead.
@@ -22,10 +22,17 @@ goes into one.
22
22
 
23
23
  The consumer is `@pikku/inspector`, whose `findPiiPaths` reads the level union
24
24
  directly and whose PKU910 output check detects the brand on a function's return
25
- type. The brands are populated from `-- @private` / `-- @secret` / `-- @public`
26
- SQL comment annotations via `pikku db migrate`, which regenerates
25
+ type. The brands are populated from the hand-authored `db/annotations.ts`
26
+ (`DbClassificationMap`) via `pikku db migrate`, which regenerates
27
27
  `outDir/db/schema.d.ts` and `outDir/db/classification.gen.ts`.
28
28
 
29
+ A column's **at-rest form** is a separate axis making the opposite trade — see
30
+ [form is an axis of its own](core-column-form-is-an-axis-of-its-own.md). Its
31
+ brands are nominal and required, which is safe precisely because they land on
32
+ the INSERT/UPDATE side of the columns that opt in, rather than on the SELECT
33
+ side of every classified column. That is not an exception to the rule below; it
34
+ is a different rule about a different side.
35
+
29
36
  **What this rules out:** making `__classification__` required to get stronger
30
37
  guarantees, or replacing the optional property with a unique symbol / nominal
31
38
  brand that behaves like a required one. Either change compiles here and then
@@ -38,6 +38,7 @@ caller is entitled to assume.
38
38
  - [CLI parse errors are routed by message prefix](cli-parse-errors-are-routed-by-message-prefix.md) — The CLI runners decide between printing help and printing errors by string-matching the prefixes the parser writes, so those message strings are an interface
39
39
  - [CLI stdout is reserved for machine-readable output](cli-stdout-is-reserved-for-machine-readable-output.md) — The default renderer emits single-line NDJSON, diagnostics go to stderr, and --json only hijacks rendering for commands that declared a renderer
40
40
  - [CLI unknown long options warn instead of failing](cli-unknown-long-options-warn-instead-of-failing.md) — Unrecognised --long options are accepted, warned about and dropped so older binaries tolerate newer invocations, while unknown short flags stay hard errors
41
+ - [A column's at-rest form is an axis of its own](core-column-form-is-an-axis-of-its-own.md) — How a value is stored is independent of how sensitive it is, so form carries a required nominal brand on writes while classification stays optional on reads
41
42
  - [The data-classification brand is an optional property](core-data-classification-brand-is-an-optional-property.md) — Making __classification__ required would break ordinary Kysely operands, so the brand only constrains values flowing out
42
43
  - [The function runner restores the wire fields it overwrites](core-function-runner-restores-the-wire-fields-it-overwrites.md) — One wire object is reused across nested calls, so functionId, audit, addonNamespace and rpc are saved and put back in a finally
43
44
  - [Hot reload merges generated meta and never replaces it](core-hot-reload-merges-generated-meta-never-replaces-it.md) — Reloading codegen output must preserve runtime-registered meta, which no generated JSON contains
@@ -77,8 +78,11 @@ caller is entitled to assume.
77
78
  - [The in-memory workflow service is inline-only and single-process](the-in-memory-workflow-service-is-inline-only-and-single-process.md) — InMemoryWorkflowService wires no queues and implements withRunLock/withStepLock as pass-throughs, because inline execution has no second holder to exclude
78
79
  - [The KEK salt is scoped to the key version, not the secret](the-kek-salt-is-scoped-to-the-key-version.md) — One stored salt per key version means N secrets cost one derivation, which is the point of envelope encryption
79
80
  - [The schema service is never stubbed, or tests validate nothing](the-schema-service-is-never-stubbed.md) — createStubProxy returns undefined for the schema property so the real schema service is built — a stubbed one turns validation into a silent no-op
81
+ - [One project-shape check, called by both validators](one-project-shape-check-two-validators.md) — workspace validate and fabric validate were separate walks over the same project that duplicated sixteen findings verbatim; the shared half now lives in shared-checks.ts
82
+ - [Scenarios, steps and personas live in files named for them](scenarios-live-in-files-named-for-them.md) — pikkuScenario/pikkuScenarioStep must be in *.scenario.ts/*.steps.ts and definePersonas in *.virtual-user.ts, an error because the mixing is only cheap to undo while it is one file
80
83
  - [Trigger declaration is split from trigger source](trigger-declaration-is-split-from-trigger-source.md) — Triggers are declared everywhere but subscribed only in the trigger worker, so app processes never open the underlying subscription
81
84
  - [TypedSecretService caches for the process lifetime](typed-secret-service-caches-for-the-process-lifetime.md) — Resolved secrets are cached with no TTL, so a secret rotated out of band is not picked up until restart — tracked as pikkujs/pikku#964
85
+ - [Both validators check personas through one shared module, and a persona counts from either the meta or the source](validate-checks-personas-through-a-shared-module.md) — workspace validate and fabric validate are separate implementations, so the persona/actor/knowledge checks live in persona-checks.ts and are called by both
82
86
  - [Webhook delivery history records every attempt, best effort](webhook-delivery-history-records-every-attempt-best-effort.md) — The webhook worker persists each attempt before it throws, and a failure to persist is logged rather than allowed to mask the delivery result
83
87
  - [Webhook service collaborators are constructor args, not locator lookups](webhook-service-collaborators-are-constructor-args-not-locator-lookups.md) — QueueWebhookService takes its queue as a constructor parameter so a project wiring webhooks without a queue fails to compile instead of at first send
84
88
  - [Whether a run is inline is read from the run record](whether-a-run-is-inline-is-read-from-the-run-record.md) — The runContexts map is a read-through cache over WorkflowRun.inline and a lifetime for replay ordinals, never the answer to what a run is
@@ -0,0 +1,53 @@
1
+ ---
2
+ type: decision
3
+ title: One project-shape check, called by both validators
4
+ description: workspace validate and fabric validate were separate walks over the same project that duplicated sixteen findings verbatim; the shared half now lives in shared-checks.ts and fabric validate is that plus the deploy-shaped checks
5
+ tags: cli, validate, fabric
6
+ ---
7
+
8
+ # One project-shape check, called by both validators
9
+
10
+ `pikku workspace validate` and `pikku fabric validate` read like one command
11
+ with a flag. They were two implementations — 495 lines and 2026 — that walked
12
+ the same project and emitted sixteen identical findings. `functions-dir-missing`
13
+ existed character for character in both files. All sixteen already agreed on
14
+ severity, which is the only reason the duplication was invisible.
15
+
16
+ What the duplication cost was not the lines. It was that each validator carried
17
+ checks the other lacked for no reason anyone could name, and nobody noticed
18
+ because nobody read them side by side:
19
+
20
+ - fabric validate never checked that `packages/functions` declares zod v4, or
21
+ that it has a `package.json` at all — while it did check that six other
22
+ dependencies are present.
23
+ - workspace validate never checked the four scaffold flags beyond `console`,
24
+ though the console it requires is useless without `rpc`.
25
+ - fabric validate swallowed a JSON parse error and reported
26
+ `pikku-config-missing` for a file sitting right there.
27
+
28
+ Two of workspace validate's own checks were not merely absent from fabric — they
29
+ had never run anywhere:
30
+
31
+ - the auth checks were gated on a middleware instance with `definitionId ===
32
+ 'betterAuthSession'`, but the CLI wires `betterAuthStatelessSession` whenever
33
+ `session.cookieCache` is on, which is the configuration Fabric asks for. So
34
+ they skipped precisely the apps most likely to have auth.
35
+ - they looked for migrations in `packages/functions/db/`, and for tables named
36
+ `app_user` and `auth_verification_token`. `pikku db migrate` reads migrations
37
+ from `<root>/db/<engine>/`, and those two table names appear nowhere else in
38
+ pikku or in any template. Had the gate ever opened, the check would have been
39
+ wrong for every project it fired on.
40
+
41
+ `shared-checks.ts` is now the half that is true of any pikku project regardless
42
+ of where it deploys. Workspace validate is exactly that module; fabric validate
43
+ is that module plus the deploy-shaped checks — themes, frontends, the
44
+ Cloudflare adapter, the `.gitignore` contract, the frontend type-check.
45
+
46
+ Fabric's fix hints won where the two differed: they are multi-line and show the
47
+ config block to paste, and there was no reason workspace users were getting the
48
+ one-line version.
49
+
50
+ **What this rules out:** adding a check to one validator and not the other;
51
+ `pikku fabric validate` reporting a corrupt config as a missing one; asserting
52
+ auth table names no scaffold generates; and reading migrations from anywhere
53
+ other than where `pikku db migrate` reads them.
@@ -0,0 +1,48 @@
1
+ ---
2
+ type: decision
3
+ title: Scenarios, steps and personas live in files named for them, and validate errors when they do not
4
+ description: pikkuScenario/pikkuFeature/pikkuScenarioStep must be in *.scenario.ts, *.scenarios.ts or *.steps.ts, and definePersonas/runVirtualUser in *.virtual-user.ts or *.vu.ts — an error, because the mixing is only cheap to undo while it is one file
5
+ tags: cli, validate, scenarios, personas
6
+ ---
7
+
8
+ # Scenarios, steps and personas live in files named for them, and validate errors when they do not
9
+
10
+ A `pikkuScenario` declared beside the functions it exercises reads as more of
11
+ the same file. The wiring, the function, and the test of the function arrive in
12
+ one scroll, and the scenario is the part that gets lost — you cannot tell from
13
+ a directory listing which files ship and which files test.
14
+
15
+ So `validate` requires the declaration to be in a file named for what it is:
16
+
17
+ | declaration | file |
18
+ | --- | --- |
19
+ | `pikkuScenario`, `pikkuFeature` | `*.scenario.ts`, `*.scenarios.ts` |
20
+ | `pikkuScenarioStep` and its platform/addon variants | `*.steps.ts` (or the above) |
21
+ | `definePersonas`, `runVirtualUser` | `*.virtual-user.ts`, `*.vu.ts` |
22
+
23
+ Three suffixes for scenarios rather than one, because the split that matters is
24
+ scenarios apart from application code, not a particular spelling — `.steps.ts`
25
+ holds the steps, `.scenarios.ts` holds the scenarios that call them, and a
26
+ project that already made that split does not have to rename anything.
27
+
28
+ **Error, not warning.** The mixing is cheap to undo while it is one file and
29
+ expensive once it is thirty, and a warning is exactly the signal a project
30
+ learns to scroll past. This is the one place where the scenario checks are
31
+ strict: everything else `validate` says about scenarios (no personas declared,
32
+ no actor sign-in, no environments) is a warning, because those describe an
33
+ under-tested project rather than an unreadable one.
34
+
35
+ **Personas are the virtual-user file** because there is no `defineVirtualUsers`
36
+ to look for. That name was retired in favour of `definePersonas`: a virtual user
37
+ is derived rather than authored — the function meta becomes its catalogue, the
38
+ scenario meta becomes its intents, the declared personas become its identities.
39
+ The persona list is the only part a project writes, so it is the part that has
40
+ to be findable by filename.
41
+
42
+ Matching is on the call (`\bpikkuScenario\s*\(`), not the import, so an aliased
43
+ import cannot slip past and a mention in a comment or a string does not trip it.
44
+ Generated files are exempt — codegen puts things where it likes.
45
+
46
+ **What this rules out:** a scenario in a function file; a step in a wiring file;
47
+ personas in a general-purpose `personas.ts`; and reporting any of these as a
48
+ warning that a project can carry indefinitely.