@pikku/core 0.12.17 → 0.12.19

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 (58) hide show
  1. package/CHANGELOG.md +20 -13
  2. package/dist/function/function-runner.js +22 -1
  3. package/dist/function/functions.types.d.ts +1 -0
  4. package/dist/services/in-memory-session-store.d.ts +8 -0
  5. package/dist/services/in-memory-session-store.js +12 -0
  6. package/dist/services/in-memory-workflow-service.d.ts +5 -2
  7. package/dist/services/in-memory-workflow-service.js +3 -1
  8. package/dist/services/index.d.ts +2 -0
  9. package/dist/services/index.js +1 -0
  10. package/dist/services/session-store.d.ts +6 -0
  11. package/dist/services/session-store.js +1 -0
  12. package/dist/services/user-session-service.d.ts +12 -10
  13. package/dist/services/user-session-service.js +18 -21
  14. package/dist/services/workflow-service.d.ts +5 -2
  15. package/dist/testing/service-tests.d.ts +2 -0
  16. package/dist/testing/service-tests.js +50 -11
  17. package/dist/types/core.types.d.ts +3 -0
  18. package/dist/wirings/channel/channel-store.d.ts +5 -6
  19. package/dist/wirings/channel/local/local-channel-runner.js +7 -4
  20. package/dist/wirings/channel/serverless/serverless-channel-runner.js +25 -8
  21. package/dist/wirings/cli/channel/cli-channel-runner.js +4 -2
  22. package/dist/wirings/cli/cli-runner.js +5 -3
  23. package/dist/wirings/http/http-runner.js +1 -1
  24. package/dist/wirings/mcp/mcp-runner.js +1 -1
  25. package/dist/wirings/scheduler/scheduler-runner.js +1 -1
  26. package/dist/wirings/workflow/graph/graph-runner.js +4 -1
  27. package/dist/wirings/workflow/index.d.ts +1 -1
  28. package/dist/wirings/workflow/pikku-workflow-service.d.ts +5 -2
  29. package/dist/wirings/workflow/pikku-workflow-service.js +6 -2
  30. package/dist/wirings/workflow/workflow.types.d.ts +14 -0
  31. package/package.json +1 -1
  32. package/src/function/function-runner.ts +36 -1
  33. package/src/function/functions.types.ts +5 -14
  34. package/src/services/in-memory-session-store.test.ts +58 -0
  35. package/src/services/in-memory-session-store.ts +21 -0
  36. package/src/services/in-memory-workflow-service.test.ts +21 -0
  37. package/src/services/in-memory-workflow-service.ts +8 -1
  38. package/src/services/index.ts +2 -0
  39. package/src/services/session-store.ts +9 -0
  40. package/src/services/user-session-service.test.ts +53 -19
  41. package/src/services/user-session-service.ts +21 -22
  42. package/src/services/workflow-service.ts +6 -1
  43. package/src/testing/service-tests.ts +64 -11
  44. package/src/types/core.types.ts +3 -0
  45. package/src/wirings/channel/channel-store.ts +5 -8
  46. package/src/wirings/channel/local/local-channel-runner.ts +10 -4
  47. package/src/wirings/channel/local/local-eventhub-service.test.ts +0 -5
  48. package/src/wirings/channel/serverless/serverless-channel-runner.ts +30 -9
  49. package/src/wirings/cli/channel/cli-channel-runner.ts +4 -2
  50. package/src/wirings/cli/cli-runner.ts +7 -3
  51. package/src/wirings/http/http-runner.ts +3 -1
  52. package/src/wirings/mcp/mcp-runner.ts +3 -1
  53. package/src/wirings/scheduler/scheduler-runner.ts +1 -1
  54. package/src/wirings/workflow/graph/graph-runner.ts +5 -1
  55. package/src/wirings/workflow/index.ts +1 -0
  56. package/src/wirings/workflow/pikku-workflow-service.ts +13 -3
  57. package/src/wirings/workflow/workflow.types.ts +15 -0
  58. package/tsconfig.tsbuildinfo +1 -1
@@ -34,7 +34,7 @@ export const runChannelConnect = async ({ channelId, channelObject, request, res
34
34
  if (request instanceof Request) {
35
35
  http = createHTTPWire(new PikkuFetchHTTPRequest(request), response);
36
36
  }
37
- const userSession = new PikkuSessionService(channelStore, channelId);
37
+ const userSession = new PikkuSessionService(singletonServices.sessionStore);
38
38
  const { channelConfig, openingData, meta } = await openChannel({
39
39
  channelId,
40
40
  request,
@@ -77,6 +77,11 @@ export const runChannelConnect = async ({ channelId, channelObject, request, res
77
77
  channelMiddlewareMeta: meta.channelMiddleware,
78
78
  });
79
79
  }
80
+ // Store the pikkuUserId mapping after auth middleware has run
81
+ const pikkuUserId = userSession.getPikkuUserId();
82
+ if (pikkuUserId) {
83
+ await channelStore.setPikkuUserId(channelId, pikkuUserId);
84
+ }
80
85
  http?.response?.status(101);
81
86
  }
82
87
  catch (e) {
@@ -98,21 +103,27 @@ export const runChannelDisconnect = async ({ channelId, channelStore, channelHan
98
103
  // there's nothing to disconnect, so we can return early.
99
104
  let channelData;
100
105
  try {
101
- channelData = await channelStore.getChannelAndSession(channelId);
106
+ channelData = await channelStore.getChannel(channelId);
102
107
  }
103
108
  catch {
104
109
  singletonServices.logger.info(`Channel ${channelId} not found during disconnect - already cleaned up`);
105
110
  return;
106
111
  }
107
- const { openingData, channelName, session } = channelData;
112
+ const { openingData, channelName, pikkuUserId } = channelData;
108
113
  const { channel, channelConfig, meta } = getVariablesForChannel({
109
114
  channelId,
110
115
  channelHandlerFactory,
111
116
  openingData,
112
117
  channelName,
113
118
  });
114
- const userSession = new PikkuSessionService(channelStore, channelId);
115
- userSession.setInitial(session);
119
+ const userSession = new PikkuSessionService(singletonServices.sessionStore);
120
+ if (pikkuUserId) {
121
+ userSession.setPikkuUserId(pikkuUserId);
122
+ const session = await singletonServices.sessionStore?.get(pikkuUserId);
123
+ if (session) {
124
+ userSession.setInitial(session);
125
+ }
126
+ }
116
127
  const wire = {
117
128
  channel,
118
129
  ...createMiddlewareSessionWireProps(userSession),
@@ -149,15 +160,21 @@ export const runChannelMessage = async ({ channelId, channelStore, channelHandle
149
160
  const singletonServices = getSingletonServices();
150
161
  const createWireServices = getCreateWireServices();
151
162
  let wireServices;
152
- const { openingData, channelName, session } = await channelStore.getChannelAndSession(channelId);
163
+ const { openingData, channelName, pikkuUserId } = await channelStore.getChannel(channelId);
153
164
  const { channel, channelHandler, channelConfig } = getVariablesForChannel({
154
165
  channelId,
155
166
  channelHandlerFactory,
156
167
  openingData,
157
168
  channelName,
158
169
  });
159
- const userSession = new PikkuSessionService(channelStore, channelId);
160
- userSession.setInitial(session);
170
+ const userSession = new PikkuSessionService(singletonServices.sessionStore);
171
+ if (pikkuUserId) {
172
+ userSession.setPikkuUserId(pikkuUserId);
173
+ const session = await singletonServices.sessionStore?.get(pikkuUserId);
174
+ if (session) {
175
+ userSession.setInitial(session);
176
+ }
177
+ }
161
178
  const wire = {
162
179
  channel,
163
180
  ...createMiddlewareSessionWireProps(userSession),
@@ -1,10 +1,12 @@
1
1
  import { pikkuState } from '../../../pikku-state.js';
2
2
  import { generateCommandHelp, parseCLIArguments } from '../command-parser.js';
3
3
  /**
4
- * Default JSON renderer for CLI output
4
+ * Default JSON renderer for CLI output. Emits single-line NDJSON so
5
+ * the output stays machine-parseable when the CLI is run with
6
+ * `--output json`; pretty-printing here would break NDJSON consumers.
5
7
  */
6
8
  const defaultJSONRenderer = (_services, data) => {
7
- console.log(JSON.stringify(data, null, 2));
9
+ console.log(JSON.stringify(data));
8
10
  };
9
11
  /**
10
12
  * Execute a CLI program via WebSocket channel
@@ -17,10 +17,12 @@ export class CLIError extends Error {
17
17
  }
18
18
  }
19
19
  /**
20
- * Default JSON renderer for CLI output
20
+ * Default JSON renderer for CLI output. Emits single-line NDJSON so
21
+ * the output stays machine-parseable when the CLI is run with
22
+ * `--output json`; pretty-printing here would break NDJSON consumers.
21
23
  */
22
24
  const defaultJSONRenderer = (_services, data) => {
23
- console.log(JSON.stringify(data, null, 2));
25
+ console.log(JSON.stringify(data));
24
26
  };
25
27
  /**
26
28
  * Registers a CLI command tree and all its functions
@@ -224,7 +226,7 @@ export async function runCLICommand({ program, commandPath, data, singletonServi
224
226
  },
225
227
  state: 'open',
226
228
  };
227
- const userSession = new PikkuSessionService();
229
+ const userSession = new PikkuSessionService(singletonServices.sessionStore);
228
230
  const wire = {
229
231
  cli: {
230
232
  program,
@@ -201,9 +201,9 @@ export const createHTTPWire = (request, response) => {
201
201
  * @throws Throws errors like MissingSessionError or ForbiddenError on validation failures.
202
202
  */
203
203
  const executeRoute = async (services, matchedRoute, http, options) => {
204
- const userSession = new PikkuSessionService();
205
204
  const { params, route, meta } = matchedRoute;
206
205
  const { singletonServices, createWireServices, skipUserSession, requestId } = services;
206
+ const userSession = new PikkuSessionService(singletonServices.sessionStore);
207
207
  // Attach URL parameters to the request object
208
208
  http?.request?.setParams(params);
209
209
  // Validate request headers if schema is defined
@@ -103,7 +103,7 @@ async function runMCPPikkuFunc(request, type, name, mcp, pikkuFuncId, { mcp: mcp
103
103
  throw new NotFoundError(`MCP '${type}' PikkuFunction Mapping not found for '${name}'`);
104
104
  }
105
105
  singletonServices.logger.debug(`Running MCP ${type}: ${name}`);
106
- const mcpSessionService = new PikkuSessionService();
106
+ const mcpSessionService = new PikkuSessionService(singletonServices.sessionStore);
107
107
  const wire = {
108
108
  mcp: mcpWire,
109
109
  ...createMiddlewareSessionWireProps(mcpSessionService),
@@ -41,7 +41,7 @@ export async function runScheduledTask({ name, session, traceId, }) {
41
41
  singletonServices.logger;
42
42
  const task = pikkuState(null, 'scheduler', 'tasks').get(name);
43
43
  const meta = pikkuState(null, 'scheduler', 'meta')[name];
44
- const userSession = new PikkuSessionService();
44
+ const userSession = new PikkuSessionService(singletonServices.sessionStore);
45
45
  if (session) {
46
46
  userSession.set(session);
47
47
  }
@@ -580,7 +580,10 @@ export async function runWorkflowGraph(workflowService, graphName, triggerInput,
580
580
  if (!meta.graphHash) {
581
581
  throw new Error(`Workflow graph '${graphName}': missing graphHash in meta`);
582
582
  }
583
- const runId = await workflowService.createRun(graphName, triggerInput, inline ?? false, meta.graphHash, wire ?? { type: 'unknown' });
583
+ const runId = await workflowService.createRun(graphName, triggerInput, inline ?? false, meta.graphHash, wire ?? { type: 'unknown' }, {
584
+ deterministic: meta.deterministic,
585
+ plannedSteps: meta.plannedSteps,
586
+ });
584
587
  if (inline) {
585
588
  workflowService.registerInlineRun(runId);
586
589
  }
@@ -8,5 +8,5 @@ export { pikkuWorkflowGraph, type PikkuWorkflowGraphConfig, type PikkuWorkflowGr
8
8
  export { validateWorkflowWiring, computeEntryNodeIds, } from './graph/graph-validation.js';
9
9
  export { pikkuWorkflowWorkerFunc, pikkuWorkflowOrchestratorFunc, pikkuWorkflowSleeperFunc, } from './workflow-queue-workers.js';
10
10
  export type { WorkflowStepInput as WorkflowStepQueueInput, PikkuWorkflowOrchestratorInput, PikkuWorkflowSleeperInput, } from './workflow-queue-workers.js';
11
- export type { WorkflowService, WorkflowServiceConfig, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, StepStatus, WorkflowRun, WorkflowRunStatus, StepState, WorkflowRunService, CoreWorkflow, PikkuWorkflow, ContextVariable, WorkflowContext, WorkflowsMeta, WorkflowRuntimeMeta, WorkflowsRuntimeMeta, WorkflowStepInput, WorkflowOrchestratorInput, WorkflowSleeperInput, } from './workflow.types.js';
11
+ export type { WorkflowService, WorkflowServiceConfig, WorkflowPlannedStep, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, StepStatus, WorkflowRun, WorkflowRunStatus, StepState, WorkflowRunService, CoreWorkflow, PikkuWorkflow, ContextVariable, WorkflowContext, WorkflowsMeta, WorkflowRuntimeMeta, WorkflowsRuntimeMeta, WorkflowStepInput, WorkflowOrchestratorInput, WorkflowSleeperInput, } from './workflow.types.js';
12
12
  export type { WorkflowStepOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, } from './workflow.types.js';
@@ -1,5 +1,5 @@
1
1
  import type { SerializedError } from '../../types/core.types.js';
2
- import type { PikkuWorkflowWire, StepState, WorkflowRun, WorkflowRunStatus, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from './workflow.types.js';
2
+ import type { PikkuWorkflowWire, StepState, WorkflowPlannedStep, WorkflowRun, WorkflowRunStatus, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from './workflow.types.js';
3
3
  import type { WorkflowService } from '../../services/workflow-service.js';
4
4
  import { PikkuError } from '../../errors/error-handler.js';
5
5
  /**
@@ -62,7 +62,10 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
62
62
  */
63
63
  unregisterInlineRun(runId: string): void;
64
64
  registerWorkflowVersions(): Promise<void>;
65
- abstract createRun(workflowName: string, input: any, inline: boolean, graphHash: string, wire: WorkflowRunWire): Promise<string>;
65
+ abstract createRun(workflowName: string, input: any, inline: boolean, graphHash: string, wire: WorkflowRunWire, options?: {
66
+ deterministic?: boolean;
67
+ plannedSteps?: WorkflowPlannedStep[];
68
+ }): Promise<string>;
66
69
  /**
67
70
  * Get a workflow run by ID
68
71
  * @param id - Run ID
@@ -231,6 +231,8 @@ export class PikkuWorkflowService {
231
231
  status: run.status,
232
232
  startedAt: run.createdAt,
233
233
  completedAt: terminalStatuses.has(run.status) ? run.updatedAt : undefined,
234
+ deterministic: run.deterministic,
235
+ plannedSteps: run.plannedSteps,
234
236
  steps,
235
237
  output: run.status === 'completed' ? run.output : undefined,
236
238
  error: run.error
@@ -326,7 +328,10 @@ export class PikkuWorkflowService {
326
328
  const shouldInline = options?.inline ||
327
329
  workflowMeta.inline ||
328
330
  !getSingletonServices()?.queueService;
329
- const runId = await this.createRun(name, input, shouldInline, workflowMeta.graphHash, wire);
331
+ const runId = await this.createRun(name, input, shouldInline, workflowMeta.graphHash, wire, {
332
+ deterministic: workflowMeta.deterministic,
333
+ plannedSteps: workflowMeta.plannedSteps,
334
+ });
330
335
  if (shouldInline) {
331
336
  this.inlineRuns.add(runId);
332
337
  try {
@@ -425,7 +430,6 @@ export class PikkuWorkflowService {
425
430
  const wire = {
426
431
  workflow: workflowWire,
427
432
  pikkuUserId: run.wire?.pikkuUserId,
428
- session: rpcService.wire?.session,
429
433
  rpc: rpcService.wire?.rpc,
430
434
  };
431
435
  try {
@@ -18,6 +18,10 @@ export interface WorkflowServiceConfig {
18
18
  stepWorkerQueueName: string;
19
19
  sleeperRPCName: string;
20
20
  }
21
+ export interface WorkflowPlannedStep {
22
+ /** Human-readable step label for UI timeline */
23
+ stepName: string;
24
+ }
21
25
  /**
22
26
  * Workflow run status
23
27
  */
@@ -50,6 +54,10 @@ export interface WorkflowRun {
50
54
  inline?: boolean;
51
55
  /** Graph hash of the workflow definition at run creation time */
52
56
  graphHash?: string;
57
+ /** True when the workflow has a static, pre-computable step timeline */
58
+ deterministic?: boolean;
59
+ /** Static planned steps snapshot captured at run start */
60
+ plannedSteps?: WorkflowPlannedStep[];
53
61
  /** Wire origin info (how this run was started) */
54
62
  wire: WorkflowRunWire;
55
63
  /** Creation timestamp */
@@ -95,6 +103,8 @@ export interface WorkflowRunStatus {
95
103
  status: WorkflowStatus;
96
104
  startedAt: Date;
97
105
  completedAt?: Date;
106
+ deterministic?: boolean;
107
+ plannedSteps?: WorkflowPlannedStep[];
98
108
  steps: Array<{
99
109
  name: string;
100
110
  status: StepStatus;
@@ -211,6 +221,10 @@ export interface WorkflowRuntimeMeta {
211
221
  entryNodeIds?: string[];
212
222
  /** Hash of graph topology (nodes, edges, input mappings) */
213
223
  graphHash?: string;
224
+ /** True when the workflow has a static, pre-computable step timeline */
225
+ deterministic?: boolean;
226
+ /** Static planned steps metadata for deterministic workflows */
227
+ plannedSteps?: WorkflowPlannedStep[];
214
228
  }
215
229
  /**
216
230
  * Unified workflow runtime metadata map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.17",
3
+ "version": "0.12.19",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -26,15 +26,44 @@ import type {
26
26
  } from './functions.types.js'
27
27
  import { parseVersionedId } from '../version.js'
28
28
  import type { SessionService } from '../services/user-session-service.js'
29
- import { createFunctionSessionWireProps } from '../services/user-session-service.js'
29
+ import {
30
+ PikkuSessionService,
31
+ createFunctionSessionWireProps,
32
+ } from '../services/user-session-service.js'
30
33
  import { ForbiddenError, ReadonlySessionError } from '../errors/errors.js'
31
34
  import {
32
35
  PikkuCredentialWireService,
33
36
  createWireServicesCredentialWireProps,
34
37
  } from '../services/credential-wire-service.js'
38
+ import { defaultPikkuUserIdResolver } from '../services/pikku-user-id.js'
35
39
  import { rpcService } from '../wirings/rpc/rpc-runner.js'
36
40
  import { closeWireServices } from '../utils.js'
37
41
 
42
+ async function resolveSession(
43
+ wire: PikkuWire,
44
+ singletonServices: CoreSingletonServices,
45
+ sessionService?: SessionService<CoreUserSession>
46
+ ): Promise<void> {
47
+ const pikkuUserId = defaultPikkuUserIdResolver(wire)
48
+ if (pikkuUserId) {
49
+ wire.pikkuUserId = pikkuUserId
50
+ if (sessionService instanceof PikkuSessionService) {
51
+ sessionService.setPikkuUserId(pikkuUserId)
52
+ }
53
+ }
54
+
55
+ const { sessionStore } = singletonServices
56
+ if (!sessionStore || !pikkuUserId) return
57
+
58
+ if (!wire.session) {
59
+ const stored = await sessionStore.get(pikkuUserId)
60
+ if (stored) {
61
+ wire.session = stored
62
+ sessionService?.setInitial(stored)
63
+ }
64
+ }
65
+ }
66
+
38
67
  /**
39
68
  * Get or create singleton services for an addon package.
40
69
  * Services are cached in pikkuState to avoid recreation on each call.
@@ -256,6 +285,12 @@ export const runPikkuFunc = async <In = any, Out = any>(
256
285
 
257
286
  // Helper function to run permissions and execute the function
258
287
  const executeFunction = async () => {
288
+ await resolveSession(
289
+ resolvedWire,
290
+ resolvedSingletonServices,
291
+ sessionService
292
+ )
293
+
259
294
  if (sessionService) {
260
295
  resolvedWire.session = sessionService.freezeInitial()
261
296
  resolvedWire.setSession = (s: any) => sessionService.set(s)
@@ -74,15 +74,8 @@ export type CorePikkuFunctionSessionless<
74
74
  export type CorePikkuPermission<
75
75
  In = any,
76
76
  Services extends CoreSingletonServices = CoreServices,
77
- Wire extends PikkuWire<
78
- In,
79
- never,
80
- false,
81
- CoreUserSession,
82
- any,
83
- never,
84
- never
85
- > = PikkuWire<In, never, false, CoreUserSession, never, never, never>,
77
+ Wire extends PikkuWire<In, never, false, CoreUserSession, any, never, never> =
78
+ PikkuWire<In, never, false, CoreUserSession, never, never, never>,
86
79
  > = (services: Services, data: In, wire: Wire) => Promise<boolean>
87
80
 
88
81
  /**
@@ -269,11 +262,8 @@ export type CorePikkuFunctionConfig<
269
262
  PikkuFunction extends
270
263
  | CorePikkuFunction<any, any, any, any, any>
271
264
  | CorePikkuFunctionSessionless<any, any, any, any, any>,
272
- PikkuPermission extends CorePikkuPermission<
273
- any,
274
- any,
275
- any
276
- > = CorePikkuPermission<any>,
265
+ PikkuPermission extends CorePikkuPermission<any, any, any> =
266
+ CorePikkuPermission<any>,
277
267
  PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<
278
268
  any,
279
269
  any
@@ -292,6 +282,7 @@ export type CorePikkuFunctionConfig<
292
282
  remote?: boolean
293
283
  mcp?: boolean
294
284
  readonly?: boolean
285
+ deploy?: 'serverless' | 'server' | 'auto'
295
286
  approvalRequired?: boolean
296
287
  approvalDescription?: any
297
288
  func: PikkuFunction
@@ -0,0 +1,58 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { InMemorySessionStore } from './in-memory-session-store.js'
5
+
6
+ describe('InMemorySessionStore', () => {
7
+ test('get returns undefined for unknown user', async () => {
8
+ const store = new InMemorySessionStore()
9
+ const result = await store.get('unknown')
10
+ assert.equal(result, undefined)
11
+ })
12
+
13
+ test('set and get round-trip', async () => {
14
+ const store = new InMemorySessionStore()
15
+ const session = { userId: 'user-1', organizationId: 'org-1' } as any
16
+ await store.set('user-1', session)
17
+
18
+ const result = await store.get('user-1')
19
+ assert.deepEqual(result, session)
20
+ })
21
+
22
+ test('set overwrites previous session', async () => {
23
+ const store = new InMemorySessionStore()
24
+ await store.set('user-1', { userId: 'user-1', role: 'admin' } as any)
25
+ await store.set('user-1', { userId: 'user-1', role: 'member' } as any)
26
+
27
+ const result = await store.get('user-1')
28
+ assert.deepEqual(result, { userId: 'user-1', role: 'member' })
29
+ })
30
+
31
+ test('clear removes session', async () => {
32
+ const store = new InMemorySessionStore()
33
+ await store.set('user-1', { userId: 'user-1' } as any)
34
+ assert.ok(await store.get('user-1'))
35
+
36
+ await store.clear('user-1')
37
+ const result = await store.get('user-1')
38
+ assert.equal(result, undefined)
39
+ })
40
+
41
+ test('clear is no-op for unknown user', async () => {
42
+ const store = new InMemorySessionStore()
43
+ await store.clear('nonexistent')
44
+ })
45
+
46
+ test('users are isolated', async () => {
47
+ const store = new InMemorySessionStore()
48
+ await store.set('user-a', { userId: 'a' } as any)
49
+ await store.set('user-b', { userId: 'b' } as any)
50
+
51
+ assert.deepEqual(await store.get('user-a'), { userId: 'a' })
52
+ assert.deepEqual(await store.get('user-b'), { userId: 'b' })
53
+
54
+ await store.clear('user-a')
55
+ assert.equal(await store.get('user-a'), undefined)
56
+ assert.deepEqual(await store.get('user-b'), { userId: 'b' })
57
+ })
58
+ })
@@ -0,0 +1,21 @@
1
+ import type { CoreUserSession } from '../types/core.types.js'
2
+ import type { SessionStore } from './session-store.js'
3
+
4
+ export class InMemorySessionStore<
5
+ UserSession extends CoreUserSession = CoreUserSession,
6
+ > implements SessionStore<UserSession>
7
+ {
8
+ private sessions = new Map<string, UserSession>()
9
+
10
+ async get(pikkuUserId: string): Promise<UserSession | undefined> {
11
+ return this.sessions.get(pikkuUserId)
12
+ }
13
+
14
+ async set(pikkuUserId: string, session: UserSession): Promise<void> {
15
+ this.sessions.set(pikkuUserId, session)
16
+ }
17
+
18
+ async clear(pikkuUserId: string): Promise<void> {
19
+ this.sessions.delete(pikkuUserId)
20
+ }
21
+ }
@@ -46,6 +46,27 @@ describe('InMemoryWorkflowService', () => {
46
46
  const state = await service.getRunState(runId)
47
47
  assert.deepStrictEqual(state, {})
48
48
  })
49
+
50
+ test('should persist deterministic planned steps metadata', async () => {
51
+ const runId = await service.createRun(
52
+ 'wf',
53
+ {},
54
+ true,
55
+ 'h',
56
+ {} as any,
57
+ {
58
+ deterministic: true,
59
+ plannedSteps: [{ stepName: 'Step 1' }, { stepName: 'Step 2' }],
60
+ }
61
+ )
62
+
63
+ const run = await service.getRun(runId)
64
+ assert.strictEqual(run?.deterministic, true)
65
+ assert.deepStrictEqual(run?.plannedSteps, [
66
+ { stepName: 'Step 1' },
67
+ { stepName: 'Step 2' },
68
+ ])
69
+ })
49
70
  })
50
71
 
51
72
  describe('getRun', () => {
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
2
2
  import { PikkuWorkflowService } from '../wirings/workflow/pikku-workflow-service.js'
3
3
  import type { SerializedError } from '../types/core.types.js'
4
4
  import type {
5
+ WorkflowPlannedStep,
5
6
  WorkflowRun,
6
7
  WorkflowRunService,
7
8
  WorkflowRunWire,
@@ -55,7 +56,11 @@ export class InMemoryWorkflowService
55
56
  input: any,
56
57
  inline: boolean,
57
58
  graphHash: string,
58
- wire: WorkflowRunWire
59
+ wire: WorkflowRunWire,
60
+ options?: {
61
+ deterministic?: boolean
62
+ plannedSteps?: WorkflowPlannedStep[]
63
+ }
59
64
  ): Promise<string> {
60
65
  const runId = randomUUID()
61
66
  const now = new Date()
@@ -67,6 +72,8 @@ export class InMemoryWorkflowService
67
72
  input,
68
73
  inline,
69
74
  graphHash,
75
+ deterministic: options?.deterministic,
76
+ plannedSteps: options?.plannedSteps,
70
77
  wire,
71
78
  createdAt: now,
72
79
  updatedAt: now,
@@ -68,6 +68,8 @@ export type {
68
68
  } from './typed-credential-service.js'
69
69
  export type { VariableStatus, VariableMeta } from './typed-variables-service.js'
70
70
  export type { MetaService } from './meta-service.js'
71
+ export type { SessionStore } from './session-store.js'
72
+ export { InMemorySessionStore } from './in-memory-session-store.js'
71
73
  export type {
72
74
  MCPMeta,
73
75
  RPCMetaRecord,
@@ -0,0 +1,9 @@
1
+ import type { CoreUserSession } from '../types/core.types.js'
2
+
3
+ export interface SessionStore<
4
+ UserSession extends CoreUserSession = CoreUserSession,
5
+ > {
6
+ get(pikkuUserId: string): Promise<UserSession | undefined>
7
+ set(pikkuUserId: string, session: UserSession): Promise<void>
8
+ clear(pikkuUserId: string): Promise<void>
9
+ }
@@ -19,17 +19,17 @@ describe('PikkuSessionService', () => {
19
19
  assert.strictEqual(service.get(), undefined)
20
20
  })
21
21
 
22
- test('should mark sessionChanged when set is called', () => {
22
+ test('should mark sessionChanged when set is called', async () => {
23
23
  const service = new PikkuSessionService()
24
24
  assert.strictEqual(service.sessionChanged, false)
25
- service.set({ userId: 'user-1' })
25
+ await service.set({ userId: 'user-1' })
26
26
  assert.strictEqual(service.sessionChanged, true)
27
27
  })
28
28
 
29
- test('should clear session', () => {
29
+ test('should clear session', async () => {
30
30
  const service = new PikkuSessionService()
31
31
  service.setInitial({ userId: 'user-1' })
32
- service.clear()
32
+ await service.clear()
33
33
  assert.strictEqual(service.get(), undefined)
34
34
  assert.strictEqual(service.sessionChanged, true)
35
35
  })
@@ -41,35 +41,69 @@ describe('PikkuSessionService', () => {
41
41
  assert.deepStrictEqual(frozen, { userId: 'user-1' })
42
42
  })
43
43
 
44
- test('should only freeze initial once', () => {
44
+ test('should only freeze initial once', async () => {
45
45
  const service = new PikkuSessionService()
46
46
  service.setInitial({ userId: 'user-1' })
47
47
  service.freezeInitial()
48
- service.set({ userId: 'user-2' })
48
+ await service.set({ userId: 'user-2' })
49
49
  const frozen2 = service.freezeInitial()
50
50
  assert.deepStrictEqual(frozen2, { userId: 'user-1' })
51
51
  })
52
52
 
53
- test('should throw when channelStore provided without channelId', () => {
54
- assert.throws(() => new PikkuSessionService({} as any), {
55
- message: 'Channel ID is required when using channel store',
56
- })
57
- })
58
-
59
- test('should use channelStore when provided', async () => {
53
+ test('should persist to sessionStore on set when pikkuUserId is set', async () => {
54
+ let storedId: string | undefined
60
55
  let storedSession: any
61
56
  const store = {
62
- setUserSession: (id: string, session: any) => {
57
+ get: async () => undefined,
58
+ set: async (id: string, session: any) => {
59
+ storedId = id
63
60
  storedSession = session
64
61
  },
65
- getChannelAndSession: (id: string) => ({ session: { userId: 'stored' } }),
62
+ clear: async () => {},
66
63
  }
67
- const service = new PikkuSessionService(store as any, 'ch-1')
68
- service.set({ userId: 'new' })
64
+ const service = new PikkuSessionService(store as any)
65
+ service.setPikkuUserId('user-123')
66
+ await service.set({ userId: 'new' })
67
+ assert.strictEqual(storedId, 'user-123')
69
68
  assert.deepStrictEqual(storedSession, { userId: 'new' })
69
+ })
70
70
 
71
- const session = service.get()
72
- assert.deepStrictEqual(session, { userId: 'stored' })
71
+ test('should not persist to sessionStore when pikkuUserId is not set', async () => {
72
+ let setCalled = false
73
+ const store = {
74
+ get: async () => undefined,
75
+ set: async () => {
76
+ setCalled = true
77
+ },
78
+ clear: async () => {},
79
+ }
80
+ const service = new PikkuSessionService(store as any)
81
+ await service.set({ userId: 'new' })
82
+ assert.strictEqual(setCalled, false)
83
+ })
84
+
85
+ test('should clear from sessionStore when pikkuUserId is set', async () => {
86
+ let clearedId: string | undefined
87
+ const store = {
88
+ get: async () => undefined,
89
+ set: async () => {},
90
+ clear: async (id: string) => {
91
+ clearedId = id
92
+ },
93
+ }
94
+ const service = new PikkuSessionService(store as any)
95
+ service.setPikkuUserId('user-123')
96
+ service.setInitial({ userId: 'user-123' })
97
+ await service.clear()
98
+ assert.strictEqual(clearedId, 'user-123')
99
+ assert.strictEqual(service.get(), undefined)
100
+ })
101
+
102
+ test('getPikkuUserId should return set value', () => {
103
+ const service = new PikkuSessionService()
104
+ assert.strictEqual(service.getPikkuUserId(), undefined)
105
+ service.setPikkuUserId('user-456')
106
+ assert.strictEqual(service.getPikkuUserId(), 'user-456')
73
107
  })
74
108
  })
75
109