@pikku/core 0.12.86 → 0.12.88

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 (36) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/dist/dev/hot-reload.d.ts +1 -1
  3. package/dist/dev/hot-reload.js +1 -1
  4. package/dist/types/index.d.ts +1 -1
  5. package/dist/types/index.js +1 -1
  6. package/dist/wirings/agent/agent-memory.d.ts +11 -0
  7. package/dist/wirings/agent/agent-memory.js +17 -2
  8. package/dist/wirings/agent/agent-stream.js +115 -91
  9. package/dist/wirings/agent/agent.types.d.ts +9 -8
  10. package/dist/wirings/variable/validate-variable-definitions.js +2 -2
  11. package/dist/wirings/variable/variable.types.d.ts +13 -7
  12. package/dist/wirings/workflow/pikku-scenario-service.d.ts +2 -2
  13. package/dist/wirings/workflow/pikku-workflow-service.d.ts +20 -6
  14. package/dist/wirings/workflow/pikku-workflow-service.js +25 -25
  15. package/dist/wirings/workflow/workflow-step-claim.d.ts +16 -0
  16. package/dist/wirings/workflow/workflow-step-claim.js +27 -0
  17. package/knowledge/decisions/security/a-scaffold-flag-says-a-surface-exists-not-who-may-call-it.md +47 -0
  18. package/knowledge/decisions/security/index.md +2 -1
  19. package/knowledge/decisions/security/scaffold-features-are-authenticated-unless-opted-out.md +6 -0
  20. package/package.json +1 -1
  21. package/src/dev/hot-reload.ts +1 -1
  22. package/src/types/index.ts +24 -1
  23. package/src/wirings/agent/agent-memory.test.ts +73 -0
  24. package/src/wirings/agent/agent-memory.ts +16 -2
  25. package/src/wirings/agent/agent-middleware.types.test.ts +41 -0
  26. package/src/wirings/agent/agent-stream-delegate.test.ts +381 -0
  27. package/src/wirings/agent/agent-stream.ts +158 -78
  28. package/src/wirings/agent/agent.types.ts +9 -8
  29. package/src/wirings/variable/validate-variable-definitions.test.ts +11 -10
  30. package/src/wirings/variable/validate-variable-definitions.ts +2 -2
  31. package/src/wirings/variable/variable.types.ts +13 -7
  32. package/src/wirings/workflow/pikku-scenario-service.ts +29 -6
  33. package/src/wirings/workflow/pikku-workflow-service.ts +31 -27
  34. package/src/wirings/workflow/workflow-step-claim.ts +46 -0
  35. package/tsconfig.tsbuildinfo +1 -1
  36. package/tsconfig.type-tests.json +2 -1
@@ -6,6 +6,7 @@ import type {
6
6
  CorePikkuMiddleware,
7
7
  MiddlewareMetadata,
8
8
  } from '../../middleware/middleware.types.js'
9
+ import type { CoreSingletonServices } from '../../types/core.types.js'
9
10
  import type { PermissionMetadata } from '../../function/function-meta.types.js'
10
11
  import type { AIProviderOptions } from '../../services/agent-runner-service.js'
11
12
  import type { PikkuChannel } from '../channel/channel.types.js'
@@ -180,10 +181,10 @@ export interface AgentToolDef extends Partial<ApprovalPolicy> {
180
181
 
181
182
  export interface PikkuAgentMiddlewareHooks<
182
183
  State extends Record<string, unknown> = Record<string, unknown>,
183
- Services = any,
184
+ SingletonServices extends CoreSingletonServices = CoreSingletonServices,
184
185
  > {
185
186
  modifyInput?: (
186
- services: Services,
187
+ services: SingletonServices,
187
188
  ctx: {
188
189
  messages: AgentMessage[]
189
190
  instructions: string
@@ -210,7 +211,7 @@ export interface PikkuAgentMiddlewareHooks<
210
211
  | { messages: AgentMessage[]; instructions: string }
211
212
 
212
213
  modifyOutputStream?: (
213
- services: Services,
214
+ services: SingletonServices,
214
215
  ctx: {
215
216
  event: AgentStreamEvent
216
217
  allEvents: readonly AgentStreamEvent[]
@@ -262,7 +263,7 @@ export interface PikkuAgentMiddlewareHooks<
262
263
  * leaves them untouched.
263
264
  */
264
265
  modifyOutput?: (
265
- services: Services,
266
+ services: SingletonServices,
266
267
  ctx: {
267
268
  text: string
268
269
  messages: AgentMessage[]
@@ -282,7 +283,7 @@ export interface PikkuAgentMiddlewareHooks<
282
283
  }
283
284
 
284
285
  beforeToolCall?: (
285
- services: Services,
286
+ services: SingletonServices,
286
287
  ctx: {
287
288
  toolName: string
288
289
  toolCallId: string
@@ -294,7 +295,7 @@ export interface PikkuAgentMiddlewareHooks<
294
295
  | void
295
296
 
296
297
  afterToolCall?: (
297
- services: Services,
298
+ services: SingletonServices,
298
299
  ctx: {
299
300
  toolName: string
300
301
  toolCallId: string
@@ -305,7 +306,7 @@ export interface PikkuAgentMiddlewareHooks<
305
306
  ) => Promise<{ result: unknown } | void> | { result: unknown } | void
306
307
 
307
308
  afterStep?: (
308
- services: Services,
309
+ services: SingletonServices,
309
310
  ctx: {
310
311
  stepNumber: number
311
312
  text: string
@@ -323,7 +324,7 @@ export interface PikkuAgentMiddlewareHooks<
323
324
  ) => Promise<void> | void
324
325
 
325
326
  onError?: (
326
- services: Services,
327
+ services: SingletonServices,
327
328
  ctx: {
328
329
  error: Error
329
330
  stepNumber: number
@@ -89,26 +89,27 @@ describe('validateAndBuildVariableDefinitionsMeta', () => {
89
89
  assert.deepStrictEqual(result, {})
90
90
  })
91
91
 
92
- test('carries required into the meta so only the opted-in ones block a deploy', () => {
92
+ test('carries optional into the meta so only the opted-out ones skip the gate', () => {
93
93
  const result = validateAndBuildVariableDefinitionsMeta(
94
94
  [
95
- {
96
- name: 'consoleUrl',
97
- displayName: 'Console URL',
98
- variableId: 'CONSOLE_URL',
99
- required: true,
100
- sourceFile: 'a.ts',
101
- },
102
95
  {
103
96
  name: 'corsOrigins',
104
97
  displayName: 'Allowed Browser Origins',
105
98
  variableId: 'CORS_ORIGINS',
99
+ optional: true,
100
+ sourceFile: 'a.ts',
101
+ },
102
+ {
103
+ name: 'consoleUrl',
104
+ displayName: 'Console URL',
105
+ variableId: 'CONSOLE_URL',
106
106
  sourceFile: 'a.ts',
107
107
  },
108
108
  ] as any,
109
109
  new Map()
110
110
  )
111
- assert.strictEqual(result['consoleUrl']!.required, true)
112
- assert.strictEqual(result['corsOrigins']!.required, undefined)
111
+ assert.strictEqual(result['corsOrigins']!.optional, true)
112
+ // Undeclared means required, which is what makes the gate ask by default.
113
+ assert.strictEqual(result['consoleUrl']!.optional, undefined)
113
114
  })
114
115
  })
@@ -43,7 +43,7 @@ export function validateAndBuildVariableDefinitionsMeta(
43
43
  description: def.description,
44
44
  variableId: def.variableId,
45
45
  schema: def.schema,
46
- required: def.required,
46
+ optional: def.optional,
47
47
  docsUrl: def.docsUrl,
48
48
  sourceFile: def.sourceFile,
49
49
  }
@@ -60,7 +60,7 @@ export function validateAndBuildVariableDefinitionsMeta(
60
60
  description: def.description,
61
61
  variableId: def.variableId,
62
62
  schema: def.schema,
63
- required: def.required,
63
+ optional: def.optional,
64
64
  docsUrl: def.docsUrl,
65
65
  sourceFile: def.sourceFile,
66
66
  }
@@ -5,13 +5,19 @@ export type CoreVariable<T = unknown> = {
5
5
  variableId: string
6
6
  schema: T
7
7
  /**
8
- * A variable is OPTIONAL by default because `variables.get` returns
9
- * `T | undefined` and never throws every caller already handles absence, so
10
- * a deploy gate that blocks on one contradicts the API. Mark a variable
11
- * `required` for the few whose absence genuinely breaks the app; only those
12
- * block a deploy.
8
+ * A variable is REQUIRED by default, and marking it `optional` is how a
9
+ * declaration says its absence is a supported state. Same flag, same
10
+ * polarity and same meaning as `CoreSecret.optional` one word to learn
11
+ * rather than two with opposite senses.
12
+ *
13
+ * Defaulting to required rather than following `variables.get`'s
14
+ * `T | undefined` return is deliberate. That signature describes what a
15
+ * caller must HANDLE, not whether a deployment is correct without the value:
16
+ * an undefined feature flag is fine, an undefined API base URL is an outage
17
+ * that the type system cannot tell apart. Declaring the difference is the
18
+ * point of the flag, and the safe default for an undeclared one is to ask.
13
19
  */
14
- required?: boolean
20
+ optional?: boolean
15
21
  docsUrl?: string
16
22
  }
17
23
 
@@ -21,7 +27,7 @@ export type VariableDefinitionMeta = {
21
27
  description?: string
22
28
  variableId: string
23
29
  schema?: Record<string, unknown> | string
24
- required?: boolean
30
+ optional?: boolean
25
31
  docsUrl?: string
26
32
  sourceFile?: string
27
33
  }
@@ -59,8 +59,34 @@ const assertionStep = <T extends WorkflowStepOptions>(
59
59
  ({ ...options, retries: options?.retries ?? 0 }) as T & { retries: number }
60
60
 
61
61
  export { addFeature, resolveFeatureScenarios } from './feature.js'
62
- export type * from './scenario.types.js'
63
- export type * from './scenario-run.types.js'
62
+ export type {
63
+ CoreFeature,
64
+ CoreFeatureScenario,
65
+ FeatureMeta,
66
+ FeaturesMeta,
67
+ PikkuBrowserWire,
68
+ PikkuScenarioWire,
69
+ ScenarioBrowserFailure,
70
+ ScenarioBrowserProvider,
71
+ ScenarioEnvironment,
72
+ ScenarioStepKind,
73
+ ScenarioStepMeta,
74
+ ScenarioStepOptions,
75
+ ScenarioStepPhase,
76
+ ScenarioSurface,
77
+ TestIdSelector,
78
+ } from './scenario.types.js'
79
+ export type {
80
+ ScenarioArtifact,
81
+ ScenarioFailureDetail,
82
+ ScenarioResult,
83
+ ScenarioRunRecord,
84
+ ScenarioRunReport,
85
+ ScenarioRunStatus,
86
+ ScenarioRunStore,
87
+ ScenarioRunSummary,
88
+ ScenarioStepRow,
89
+ } from './scenario-run.types.js'
64
90
  export { SCENARIO_SURFACES } from './scenario-step.types.js'
65
91
 
66
92
  // Which of a step's bindings run: one for an action, every witness for a `then`
@@ -979,10 +1005,7 @@ export class PikkuScenarioService implements WorkflowRunExtension {
979
1005
  * Whether a step is driven by a persona, and so must be given one. Stamped by
980
1006
  * the definer from a `browser` binding or an explicit `actor: true`.
981
1007
  */
982
- private requiresActor(
983
- packageName: string | null,
984
- stepFunc: string
985
- ): boolean {
1008
+ private requiresActor(packageName: string | null, stepFunc: string): boolean {
986
1009
  return (
987
1010
  this.scenarioStepConfig(packageName, stepFunc)?.requiresActor === true
988
1011
  )
@@ -68,7 +68,6 @@ import {
68
68
  WorkflowRunCancelledError,
69
69
  WorkflowRunFailedError,
70
70
  WorkflowRunNotFoundError,
71
- WorkflowStepFunctionMismatchError,
72
71
  WorkflowStepNameNotString,
73
72
  WorkflowSuspendedException,
74
73
  } from './workflow-errors.js'
@@ -95,6 +94,7 @@ import {
95
94
  } from './workflow-approval.js'
96
95
  import { auditApprovalDecision } from './workflow-approval-audit.js'
97
96
  import { recordSuspension, suspendStepNameFor } from './workflow-suspend.js'
97
+ import { claimStepByReadThenWrite } from './workflow-step-claim.js'
98
98
  import {
99
99
  RedispatchBackoff,
100
100
  sweepStalledRuns,
@@ -639,12 +639,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
639
639
  *
640
640
  * Returns nothing by default so a store that cannot express the query keeps
641
641
  * working unchanged — and, because it does not opt in, gains no re-dispatches
642
- * either. A store must have an atomic `withStepLock` before overriding this,
643
- * or no concurrency for one to exclude: the relay makes duplicate dispatch
644
- * routine, and the claim in `executeWorkflowStepInner` is what keeps a
645
- * duplicate from becoming a second execution. `kysely-postgres` and
646
- * `kysely-mysql` qualify on the lock, `in-memory` on being inline and
647
- * single-process; `mongodb` and `kysely-sqlite` qualify on neither.
642
+ * either. A store must have an atomic `claimStepForExecution` before
643
+ * overriding this, or no concurrency for one to exclude: the relay makes
644
+ * duplicate dispatch routine, and the claim is what keeps a duplicate from
645
+ * becoming a second execution. Every `@pikku/kysely` dialect qualifies on its
646
+ * status-guarded claim, `in-memory` on being inline and single-process;
647
+ * `mongodb` still qualifies on neither.
648
648
  */
649
649
  protected async findUndispatchedSteps(
650
650
  _before: Date,
@@ -1300,6 +1300,29 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1300
1300
  }
1301
1301
  }
1302
1302
 
1303
+ /**
1304
+ * Take sole ownership of a step before it runs, returning the state to run
1305
+ * under — or `null` when another dispatch already owns it.
1306
+ *
1307
+ * Dispatch is at-least-once by design: the relay re-dispatches steps it
1308
+ * believes were dropped, and a queue can redeliver a job it already handed
1309
+ * out. This is the one place that keeps a duplicate dispatch from becoming a
1310
+ * second execution of a side-effecting step, so it is only as strong as the
1311
+ * exclusion it is built on — and `withStepLock` excludes nothing unless the
1312
+ * store backs it with a real primitive. A store able to express the decision
1313
+ * as one conditional write should override this rather than reach for a lock,
1314
+ * which is what `@pikku/kysely` does with a status-guarded `UPDATE`.
1315
+ */
1316
+ protected async claimStepForExecution(
1317
+ runId: string,
1318
+ stepName: string,
1319
+ rpcName: string
1320
+ ): Promise<StepState | null> {
1321
+ return this.withStepLock(runId, stepName, () =>
1322
+ claimStepByReadThenWrite(this, runId, stepName, rpcName)
1323
+ )
1324
+ }
1325
+
1303
1326
  private async executeWorkflowStepInner(
1304
1327
  runId: string,
1305
1328
  stepName: string,
@@ -1307,26 +1330,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1307
1330
  data: any,
1308
1331
  rpcService: PikkuRPC
1309
1332
  ): Promise<void> {
1310
- const claimed = await this.withStepLock(runId, stepName, async () => {
1311
- const stepState = await this.getStepState(runId, stepName)
1312
- // knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
1313
- if (
1314
- stepState.rpcName !== undefined &&
1315
- stepState.rpcName !== (rpcName ?? null)
1316
- ) {
1317
- throw new WorkflowStepFunctionMismatchError(runId, stepName)
1318
- }
1319
- if (stepState.status === 'succeeded' || stepState.status === 'running') {
1320
- return null
1321
- }
1322
- if (stepState.status === 'failed') {
1323
- return this.createRetryAttempt(stepState.stepId, 'running')
1324
- }
1325
- if (stepState.status === 'pending' || stepState.status === 'scheduled') {
1326
- await this.setStepRunning(stepState.stepId)
1327
- }
1328
- return stepState
1329
- })
1333
+ const claimed = await this.claimStepForExecution(runId, stepName, rpcName)
1330
1334
 
1331
1335
  if (!claimed) {
1332
1336
  return
@@ -0,0 +1,46 @@
1
+ import { WorkflowStepFunctionMismatchError } from './workflow-errors.js'
2
+ import type { StepState } from './workflow.types.js'
3
+
4
+ /** What claiming a step needs from the workflow service. */
5
+ export type StepClaimStore = {
6
+ getStepState(runId: string, stepName: string): Promise<StepState>
7
+ setStepRunning(stepId: string): Promise<void>
8
+ createRetryAttempt(
9
+ failedStepId: string,
10
+ status: 'pending' | 'running'
11
+ ): Promise<StepState>
12
+ }
13
+
14
+ /**
15
+ * Decide whether this dispatch owns the step, by reading its state and then
16
+ * writing it — which only excludes a concurrent dispatch when the caller holds
17
+ * a lock that genuinely excludes one.
18
+ *
19
+ * A store that can express the whole decision as a single conditional write
20
+ * should do that instead of calling this.
21
+ */
22
+ export const claimStepByReadThenWrite = async (
23
+ store: StepClaimStore,
24
+ runId: string,
25
+ stepName: string,
26
+ rpcName: string
27
+ ): Promise<StepState | null> => {
28
+ const stepState = await store.getStepState(runId, stepName)
29
+ // knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
30
+ if (
31
+ stepState.rpcName !== undefined &&
32
+ stepState.rpcName !== (rpcName ?? null)
33
+ ) {
34
+ throw new WorkflowStepFunctionMismatchError(runId, stepName)
35
+ }
36
+ if (stepState.status === 'succeeded' || stepState.status === 'running') {
37
+ return null
38
+ }
39
+ if (stepState.status === 'failed') {
40
+ return store.createRetryAttempt(stepState.stepId, 'running')
41
+ }
42
+ if (stepState.status === 'pending' || stepState.status === 'scheduled') {
43
+ await store.setStepRunning(stepState.stepId)
44
+ }
45
+ return stepState
46
+ }