@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.
- package/CHANGELOG.md +397 -0
- package/dist/column-form.d.ts +32 -0
- package/dist/column-form.js +42 -0
- package/dist/crypto-utils.d.ts +15 -4
- package/dist/crypto-utils.js +18 -2
- package/dist/data-classification.d.ts +44 -0
- package/dist/function/functions.types.d.ts +23 -10
- package/dist/function/index.d.ts +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/middleware/auth-bearer.js +2 -1
- package/dist/middleware/remote-auth.js +1 -1
- package/dist/remote.js +1 -1
- package/dist/secret-value.d.ts +56 -0
- package/dist/secret-value.js +46 -0
- package/dist/services/audit-service.d.ts +74 -4
- package/dist/services/audit-service.js +7 -5
- package/dist/services/credential-wire-service.d.ts +5 -0
- package/dist/services/credential-wire-service.js +9 -1
- package/dist/services/email-service.d.ts +2 -1
- package/dist/services/in-memory-workflow-service.d.ts +1 -0
- package/dist/services/in-memory-workflow-service.js +20 -0
- package/dist/services/index.d.ts +3 -3
- package/dist/services/index.js +1 -1
- package/dist/services/local-content-request-handler.d.ts +29 -0
- package/dist/services/local-content-request-handler.js +176 -0
- package/dist/services/local-secrets.d.ts +4 -3
- package/dist/services/local-secrets.js +7 -3
- package/dist/services/logger.d.ts +22 -5
- package/dist/services/queue-webhook-service.js +1 -1
- package/dist/services/scoped-secret-service.d.ts +4 -3
- package/dist/services/secret-service.d.ts +8 -3
- package/dist/services/typed-secret-service.d.ts +5 -4
- package/dist/services/webhook-service.d.ts +2 -1
- package/dist/testing/service-tests.js +6 -6
- package/dist/types/core.types.d.ts +25 -4
- package/dist/wirings/ai-agent/ai-agent-agui.js +13 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +7 -1
- package/dist/wirings/ai-agent/ai-agent-runner.js +14 -2
- package/dist/wirings/ai-agent/ai-agent-stream.js +27 -1
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +40 -0
- package/dist/wirings/ai-agent/index.d.ts +1 -1
- package/dist/wirings/ai-agent/index.js +1 -1
- package/dist/wirings/ai-agent/voice-input.d.ts +20 -0
- package/dist/wirings/ai-agent/voice-input.js +44 -9
- package/dist/wirings/ai-agent/voice-output.d.ts +15 -0
- package/dist/wirings/ai-agent/voice-output.js +10 -1
- package/dist/wirings/cli/channel/cli-raw-client-runner.d.ts +21 -3
- package/dist/wirings/cli/channel/cli-raw-client-runner.js +13 -5
- package/dist/wirings/cli/channel/index.d.ts +1 -0
- package/dist/wirings/persona/define-personas.d.ts +4 -0
- package/dist/wirings/persona/define-personas.js +4 -0
- package/dist/wirings/persona/persona.types.d.ts +11 -0
- package/dist/wirings/queue/queue-identity.js +2 -1
- package/dist/wirings/queue/queue.types.d.ts +2 -1
- package/dist/wirings/queue/signed-queue-service.d.ts +2 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +2 -1
- package/dist/wirings/rpc/remote-addon-auth.js +6 -2
- package/dist/wirings/virtual-user/index.d.ts +3 -0
- package/dist/wirings/virtual-user/index.js +2 -0
- package/dist/wirings/virtual-user/prepare-virtual-user-run.d.ts +54 -0
- package/dist/wirings/virtual-user/prepare-virtual-user-run.js +49 -0
- package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +90 -0
- package/dist/wirings/virtual-user/virtual-user-run-store.js +1 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +14 -10
- package/dist/wirings/workflow/pikku-scenario-service.js +1 -2
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +35 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +51 -0
- package/dist/wirings/workflow/scenario-prose.js +1 -1
- package/dist/wirings/workflow/scenario-step.types.d.ts +13 -7
- package/dist/wirings/workflow/workflow.types.d.ts +7 -0
- package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +48 -0
- package/knowledge/decisions/internals/core-column-form-is-an-axis-of-its-own.md +84 -0
- package/knowledge/decisions/internals/core-data-classification-brand-is-an-optional-property.md +9 -2
- package/knowledge/decisions/internals/index.md +4 -0
- package/knowledge/decisions/internals/one-project-shape-check-two-validators.md +53 -0
- package/knowledge/decisions/internals/scenarios-live-in-files-named-for-them.md +48 -0
- package/knowledge/decisions/internals/validate-checks-personas-through-a-shared-module.md +43 -0
- package/package.json +3 -2
- package/src/column-form.test.ts +97 -0
- package/src/column-form.ts +58 -0
- package/src/crypto-utils.ts +25 -6
- package/src/data-classification.ts +44 -0
- package/src/function/functions.types.ts +47 -10
- package/src/function/index.ts +1 -0
- package/src/index.ts +24 -2
- package/src/middleware/auth-bearer.test.ts +3 -2
- package/src/middleware/auth-bearer.ts +2 -1
- package/src/middleware/remote-auth.test.ts +2 -1
- package/src/middleware/remote-auth.ts +1 -1
- package/src/remote.test.ts +2 -1
- package/src/remote.ts +1 -1
- package/src/secret-value.test.ts +204 -0
- package/src/secret-value.ts +111 -0
- package/src/services/audit-service.ts +87 -9
- package/src/services/credential-wire-service.ts +9 -1
- package/src/services/email-service.ts +3 -1
- package/src/services/in-memory-workflow-service.ts +29 -0
- package/src/services/index.ts +3 -3
- package/src/services/local-content-request-handler.test.ts +202 -0
- package/src/services/local-content-request-handler.ts +267 -0
- package/src/services/local-secrets.test.ts +20 -5
- package/src/services/local-secrets.ts +15 -7
- package/src/services/logger.ts +27 -7
- package/src/services/queue-webhook-service.test.ts +2 -1
- package/src/services/queue-webhook-service.ts +1 -1
- package/src/services/scoped-secret-service.ts +4 -3
- package/src/services/secret-service.ts +8 -3
- package/src/services/typed-secret-service.ts +11 -7
- package/src/services/webhook-service.ts +4 -1
- package/src/testing/service-tests.ts +6 -6
- package/src/types/core.types.ts +25 -4
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +16 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +14 -1
- package/src/wirings/ai-agent/ai-agent-prepare.ts +7 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +18 -2
- package/src/wirings/ai-agent/ai-agent-stream.ts +32 -1
- package/src/wirings/ai-agent/ai-agent.types.ts +45 -1
- package/src/wirings/ai-agent/index.ts +2 -0
- package/src/wirings/ai-agent/voice-input.test.ts +65 -0
- package/src/wirings/ai-agent/voice-input.ts +48 -9
- package/src/wirings/ai-agent/voice-output.test.ts +91 -1
- package/src/wirings/ai-agent/voice-output.ts +28 -1
- package/src/wirings/cli/channel/cli-raw-client-runner.ts +39 -9
- package/src/wirings/cli/channel/index.ts +4 -0
- package/src/wirings/persona/define-personas.ts +4 -0
- package/src/wirings/persona/persona.types.ts +11 -0
- package/src/wirings/queue/queue-identity.test.ts +2 -1
- package/src/wirings/queue/queue-identity.ts +4 -1
- package/src/wirings/queue/queue.types.ts +6 -1
- package/src/wirings/queue/signed-queue-service.ts +2 -1
- package/src/wirings/rpc/remote-addon-auth.ts +8 -3
- package/src/wirings/rpc/rpc-runner.test.ts +6 -4
- package/src/wirings/virtual-user/index.ts +12 -0
- package/src/wirings/virtual-user/prepare-virtual-user-run.test.ts +115 -0
- package/src/wirings/virtual-user/prepare-virtual-user-run.ts +95 -0
- package/src/wirings/virtual-user/virtual-user-run-store.ts +98 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +14 -16
- package/src/wirings/workflow/pikku-scenario-service.ts +1 -10
- package/src/wirings/workflow/pikku-workflow-service.ts +67 -0
- package/src/wirings/workflow/scenario-prose.test.ts +5 -7
- package/src/wirings/workflow/scenario-prose.ts +1 -1
- package/src/wirings/workflow/scenario-service.test.ts +0 -1
- package/src/wirings/workflow/scenario-step.test.ts +4 -5
- package/src/wirings/workflow/scenario-step.types.ts +13 -7
- package/src/wirings/workflow/scenario-surface.test.ts +6 -5
- package/src/wirings/workflow/workflow-stalled-recovery.test.ts +106 -0
- package/src/wirings/workflow/workflow.types.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
- 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.
|
|
88
|
+
* Type signature for scenario.given/when/then - used by inspector.
|
|
89
89
|
*
|
|
90
90
|
* Deliberately mirrors WorkflowWireDoRPC's shape: the target is a string, not
|
|
91
91
|
* an imported symbol, so the extractor reads it as a literal.
|
|
@@ -199,7 +199,7 @@ export interface RpcStepMeta {
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
/**
|
|
202
|
-
* Scenario step metadata — a call to `scenario.
|
|
202
|
+
* Scenario step metadata — a call to `scenario.given/when/then`.
|
|
203
203
|
*
|
|
204
204
|
* Distinct from RpcStepMeta on purpose: a step runs locally through
|
|
205
205
|
* runPikkuFunc and must never be treated as dispatchable on the queue/replay
|
|
@@ -212,7 +212,7 @@ export interface ScenarioStepMeta {
|
|
|
212
212
|
stepName: string
|
|
213
213
|
/** Registered name of the step function being run */
|
|
214
214
|
stepFunc: string
|
|
215
|
-
/** Which keyword the reporter prefixes —
|
|
215
|
+
/** Which keyword the reporter prefixes — Given, When or Then */
|
|
216
216
|
phase: ScenarioStepPhase
|
|
217
217
|
/** Output variable name (if assigned) */
|
|
218
218
|
outputVar?: string
|
|
@@ -557,20 +557,14 @@ export interface PikkuScenarioWire extends PikkuWorkflowWire {
|
|
|
557
557
|
) => Promise<void>
|
|
558
558
|
|
|
559
559
|
/**
|
|
560
|
-
* Run a registered scenario step
|
|
561
|
-
*
|
|
562
|
-
*
|
|
560
|
+
* Run a registered scenario step, as the setup the scenario starts from.
|
|
561
|
+
*
|
|
562
|
+
* Shaped exactly like `do`'s RPC form — `(stepName, target, data, options)` —
|
|
563
|
+
* so the inspector reads the target as a string literal rather than resolving
|
|
564
|
+
* an imported symbol.
|
|
563
565
|
*
|
|
564
566
|
* The generated `TypedScenario` narrows these over `FlattenedScenarioStepMap`.
|
|
565
567
|
*/
|
|
566
|
-
step(
|
|
567
|
-
stepName: string,
|
|
568
|
-
stepFunc: string,
|
|
569
|
-
data?: any,
|
|
570
|
-
options?: ScenarioStepOptions
|
|
571
|
-
): Promise<any>
|
|
572
|
-
|
|
573
|
-
/** `step` with a "Given" prefix in the rendered prose */
|
|
574
568
|
given(
|
|
575
569
|
stepName: string,
|
|
576
570
|
stepFunc: string,
|
|
@@ -578,7 +572,7 @@ export interface PikkuScenarioWire extends PikkuWorkflowWire {
|
|
|
578
572
|
options?: ScenarioStepOptions
|
|
579
573
|
): Promise<any>
|
|
580
574
|
|
|
581
|
-
/** `
|
|
575
|
+
/** `when`: the same call as `given`, rendered as the action under test */
|
|
582
576
|
when(
|
|
583
577
|
stepName: string,
|
|
584
578
|
stepFunc: string,
|
|
@@ -586,7 +580,11 @@ export interface PikkuScenarioWire extends PikkuWorkflowWire {
|
|
|
586
580
|
options?: ScenarioStepOptions
|
|
587
581
|
): Promise<any>
|
|
588
582
|
|
|
589
|
-
/**
|
|
583
|
+
/**
|
|
584
|
+
* `then`: a claim about what the action left behind. Unlike `given`/`when`
|
|
585
|
+
* this is not prose alone — the step's bindings become witnesses, so every
|
|
586
|
+
* declared surface is observed and the observations must agree.
|
|
587
|
+
*/
|
|
590
588
|
then(
|
|
591
589
|
stepName: string,
|
|
592
590
|
stepFunc: string,
|
|
@@ -649,20 +649,11 @@ export class PikkuScenarioService implements WorkflowRunExtension {
|
|
|
649
649
|
},
|
|
650
650
|
|
|
651
651
|
// Scenario steps: a named `pikkuScenarioStep` run as one durable step.
|
|
652
|
-
// `given`/`when
|
|
652
|
+
// `given`/`when` are sugar for each other, differing only in the
|
|
653
653
|
// prose a reporter renders. `then` is not: the phase is what decides
|
|
654
654
|
// whether the step's bindings are alternatives or witnesses, so the same
|
|
655
655
|
// step function called two ways runs differently. See
|
|
656
656
|
// {@link resolveScenarioSurfaces}.
|
|
657
|
-
step: (stepName, stepFunc, data, options) =>
|
|
658
|
-
this.scenarioStep(
|
|
659
|
-
'step',
|
|
660
|
-
scenarioStepContext(),
|
|
661
|
-
stepName,
|
|
662
|
-
stepFunc,
|
|
663
|
-
data,
|
|
664
|
-
options
|
|
665
|
-
),
|
|
666
657
|
given: (stepName, stepFunc, data, options) =>
|
|
667
658
|
this.scenarioStep(
|
|
668
659
|
'given',
|
|
@@ -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('
|
|
113
|
+
test('every phase renders its keyword', () => {
|
|
114
114
|
assert.equal(
|
|
115
115
|
composeStepProse({
|
|
116
|
-
phase: '
|
|
116
|
+
phase: 'when',
|
|
117
117
|
description: 'refreshes the dashboard',
|
|
118
118
|
actor: 'admin',
|
|
119
119
|
}),
|
|
120
|
-
'the admin refreshes the dashboard'
|
|
120
|
+
'When the admin refreshes the dashboard'
|
|
121
121
|
)
|
|
122
122
|
})
|
|
123
123
|
|
|
@@ -127,7 +127,6 @@ describe('composeStepProse basics', () => {
|
|
|
127
127
|
{ phase: 'given', description: 'buys an apple' },
|
|
128
128
|
{ phase: 'when', description: 'checks out' },
|
|
129
129
|
{ phase: 'then', description: 'sees a receipt' },
|
|
130
|
-
{ phase: 'step', description: 'waits' },
|
|
131
130
|
] as const
|
|
132
131
|
).map((step) =>
|
|
133
132
|
composeStepProse({ ...step, actor: 'shopper', keywordWidth: 5 })
|
|
@@ -137,7 +136,6 @@ describe('composeStepProse basics', () => {
|
|
|
137
136
|
'Given the shopper buys an apple',
|
|
138
137
|
'When the shopper checks out',
|
|
139
138
|
'Then the shopper sees a receipt',
|
|
140
|
-
' the shopper waits',
|
|
141
139
|
])
|
|
142
140
|
const columns = new Set(rendered.map((line) => line.indexOf('the shopper')))
|
|
143
141
|
assert.equal(columns.size, 1, 'every sentence starts in the same column')
|
|
@@ -145,8 +143,8 @@ describe('composeStepProse basics', () => {
|
|
|
145
143
|
|
|
146
144
|
test('prose degrades to just the description when nothing else is known', () => {
|
|
147
145
|
assert.equal(
|
|
148
|
-
composeStepProse({ phase: '
|
|
149
|
-
'does the thing'
|
|
146
|
+
composeStepProse({ phase: 'when', description: 'does the thing' }),
|
|
147
|
+
'When does the thing'
|
|
150
148
|
)
|
|
151
149
|
})
|
|
152
150
|
})
|
|
@@ -37,7 +37,7 @@ export const composeStepProse = ({
|
|
|
37
37
|
actor?: string
|
|
38
38
|
keywordWidth?: number
|
|
39
39
|
}): string => {
|
|
40
|
-
const keyword =
|
|
40
|
+
const keyword = capitalise(phase)
|
|
41
41
|
const subject = actor ? `the ${actor}` : ''
|
|
42
42
|
const rendered = template ? renderStepTemplate(template, input) : description
|
|
43
43
|
const sentence = [subject, rendered].filter(Boolean).join(' ')
|
|
@@ -155,7 +155,7 @@ const registerStep = (
|
|
|
155
155
|
} as any
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
describe('pikkuScenarioStep (scenario.
|
|
158
|
+
describe('pikkuScenarioStep (scenario.given/when/then)', () => {
|
|
159
159
|
beforeEach(() => resetPikkuState())
|
|
160
160
|
|
|
161
161
|
test('the step func is called with the phase, step identity and data on the wire', async () => {
|
|
@@ -202,12 +202,11 @@ describe('pikkuScenarioStep (scenario.step/given/when/then)', () => {
|
|
|
202
202
|
|
|
203
203
|
const runId = await setup(ws)
|
|
204
204
|
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
205
|
-
await wire.step('a', 'noop')
|
|
206
205
|
await wire.given('b', 'noop')
|
|
207
206
|
await wire.when('c', 'noop')
|
|
208
207
|
await wire.then('d', 'noop')
|
|
209
208
|
|
|
210
|
-
assert.deepEqual(phases, ['
|
|
209
|
+
assert.deepEqual(phases, ['given', 'when', 'then'])
|
|
211
210
|
})
|
|
212
211
|
|
|
213
212
|
test('the actor is handed to the step rather than used to dispatch it', async () => {
|
|
@@ -291,7 +290,7 @@ describe('pikkuScenarioStep (scenario.step/given/when/then)', () => {
|
|
|
291
290
|
|
|
292
291
|
const runId = await setup(ws)
|
|
293
292
|
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
294
|
-
const result = await wire.
|
|
293
|
+
const result = await wire.when('waits for the page', 'flaky', undefined, {
|
|
295
294
|
retries: 3,
|
|
296
295
|
retryDelay: 1,
|
|
297
296
|
})
|
|
@@ -509,7 +508,7 @@ describe('pikkuScenarioStep (scenario.step/given/when/then)', () => {
|
|
|
509
508
|
const wire = ws.createWorkflowWire('scenarioTest', runId, {})
|
|
510
509
|
|
|
511
510
|
await assert.rejects(
|
|
512
|
-
(wire.
|
|
511
|
+
(wire.when as any)('a name', async () => 'inline'),
|
|
513
512
|
/string/i
|
|
514
513
|
)
|
|
515
514
|
})
|
|
@@ -4,15 +4,21 @@ import type { ScenarioPersona } from '../../services/personas-service.js'
|
|
|
4
4
|
* Scenario steps: named, typed units of scenario behaviour.
|
|
5
5
|
*
|
|
6
6
|
* A step's body is an ordinary pikku function, so it may drive a browser, call
|
|
7
|
-
* an RPC as its actor, or run a workflow. `given
|
|
8
|
-
*
|
|
7
|
+
* an RPC as its actor, or run a workflow. `given` and `when` differ only in the
|
|
8
|
+
* prose the reporter renders; `then` also changes what runs, because its
|
|
9
|
+
* bindings are witnesses — every declared surface is observed and the
|
|
10
|
+
* observations must agree. See {@link ScenarioSurfaceResolution}.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
|
-
* Which Gherkin-style keyword the reporter prefixes this step with.
|
|
13
|
-
*
|
|
14
|
+
* Which Gherkin-style keyword the reporter prefixes this step with.
|
|
15
|
+
*
|
|
16
|
+
* Every step takes one. A scenario is read by people deciding whether it
|
|
17
|
+
* describes the behaviour they wanted, and a step that says only what it does
|
|
18
|
+
* without saying whether it is setup, action or claim is the one nobody can
|
|
19
|
+
* check — which is also why PKU680 can tell a scenario that never asserts.
|
|
14
20
|
*/
|
|
15
|
-
export type ScenarioStepPhase = '
|
|
21
|
+
export type ScenarioStepPhase = 'given' | 'when' | 'then'
|
|
16
22
|
|
|
17
23
|
/**
|
|
18
24
|
* Who acts in a step.
|
|
@@ -51,7 +57,7 @@ export const SCENARIO_SURFACES: readonly ScenarioSurface[] = [
|
|
|
51
57
|
/**
|
|
52
58
|
* How a step's declared surfaces resolve for one run.
|
|
53
59
|
*
|
|
54
|
-
* `given`/`when
|
|
60
|
+
* `given`/`when` bindings are **alternatives** — clicking Buy and calling
|
|
55
61
|
* `createOrder` are two ways to cause one effect, so exactly one runs.
|
|
56
62
|
*
|
|
57
63
|
* `then` bindings are **witnesses** — "the order row says paid" and "the
|
|
@@ -86,7 +92,7 @@ export type ScenarioSurfaceResolution =
|
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
/**
|
|
89
|
-
* Options accepted by `scenario.
|
|
95
|
+
* Options accepted by `scenario.given/when/then`.
|
|
90
96
|
*
|
|
91
97
|
* Note the retry default differs from an ordinary workflow step: retrying a
|
|
92
98
|
* failed assertion is the wrong behaviour for a test primitive, so steps
|
|
@@ -22,11 +22,12 @@ describe('action steps pick exactly one binding', () => {
|
|
|
22
22
|
})
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
test('given
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
}
|