@pikku/core 0.12.29 → 0.12.31

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 CHANGED
@@ -1,3 +1,90 @@
1
+ ## 0.12.31
2
+
3
+ ### Patch Changes
4
+
5
+ - fe70fe0: fix(db): make classified columns usable in Kysely queries and emit real zod
6
+
7
+ Two fixes so data-classified DB columns (`@private`/`@pii`/`@secret`, default
8
+ `private`) are usable end-to-end instead of poisoning ordinary app code:
9
+ 1. **Brand marker is now optional** (`{ readonly __classification__?: ... }`)
10
+ in both `@pikku/core` and the `pikku db migrate` schema header. A required
11
+ marker made a plain value (e.g. `string`) unassignable to a branded column
12
+ (`Private<string>`), breaking every Kysely `where`/insert/`.set()` operand —
13
+ any project with classified columns failed to type-check. Optional keeps the
14
+ brand structurally present (so the inspector's PKU910 output check still
15
+ detects it) while letting plain values flow IN. The inspector's level read is
16
+ now union-aware (`'pii' | undefined`) so pii/secret no longer silently
17
+ downgrade to private.
18
+ 2. **Zod codegen resolves classified `ColumnType<>`** to proper scalars instead
19
+ of `z.unknown()`. `pikku db migrate` emits `<Table>Z`/`InsertZ`/`PatchZ` from
20
+ the Select slot, unwrapping the brand and honoring insert-optionality from the
21
+ Insert slot's `| undefined`. Public `Generated<T>`/bare/nested shapes are
22
+ unchanged.
23
+
24
+ ## 0.12.30
25
+
26
+ ### Patch Changes
27
+
28
+ - cd101a5: feat(core): add `auditLog` service slot for per-invocation audit logs
29
+
30
+ `CoreSingletonServices` now declares `auditLog?: AuditLog`, giving the
31
+ per-request audit log returned by `createInvocationAudit` a typed home in the
32
+ service container. Apps wire it in `createWireServices`
33
+ (`return { auditLog, kysely: createAuditedKysely(kysely, { audit: auditLog }) }`)
34
+ and the runner flushes its buffer via `close()` when the invocation ends.
35
+
36
+ Previously there was no slot to return it from: `audit` is typed `AuditService`
37
+ (the durable sink, `.audit()`), while `createInvocationAudit` returns an
38
+ `AuditLog` (the request-scoped buffer, `.write/.flush/.close`). Returning the
39
+ buffer under `audit` was a type error, so audited-Kysely wiring could not
40
+ type-check. `auditLog` is distinct from `audit` and never shadows it.
41
+
42
+ - ac16265: fix(core): read email template assets from the absolute `emailsMeta.src` directly
43
+
44
+ `getEmailTemplateAssets` passed an absolute `baseDir` (e.g. `/project/emails`) into
45
+ `readProjectFile`, which resolves `join(basePath, '..', relativePath)`. Because
46
+ `path.join` does not treat an absolute second segment as a root reset, this produced
47
+ a non-existent compound path (`/project/packages/functions/project/emails/...`), so
48
+ every asset read returned `null` and the email preview reported all source files
49
+ (`theme, locale, html, subject, text`) as missing. Read the assets directly via
50
+ `readFile(join(baseDir, rel))` instead, which resolves correctly for an absolute
51
+ base. Verified live: a previously all-missing preview now renders.
52
+
53
+ - a05e864: fix(core): allow multiple independent suspend points in one workflow
54
+
55
+ `getSuspendStepName()` returned the constant `'__workflow_suspend'` for every
56
+ `workflow.suspend()` call, so all suspends in a run shared a single step row.
57
+ Once the first suspend resolved (row → `succeeded`), every later `suspend()`
58
+ read that same `succeeded` row and fell straight through without pausing — so a
59
+ workflow could only ever have one working suspend point, and a second one (e.g.
60
+ wait-for-build, then wait-for-approval) was silently skipped.
61
+
62
+ The suspend step is now keyed on its `reason` (used raw, just namespaced so it
63
+ can't collide with a `do`/`sleep` step of the same name), so each distinct
64
+ reason is its own step row. A workflow can now have multiple independent
65
+ suspends, including dynamic reasons in loops (`suspend(`Wait for ${i}`)`),
66
+ exactly like dynamic `do()` step names. As with `do()`/`sleep()`, the reason is
67
+ the suspend's stable identity and must be derived deterministically so it
68
+ matches on replay. `suspend(reason)` is unchanged at the call site.
69
+
70
+ - 20750fd: feat(workflow): decide step dispatch purely per-function
71
+
72
+ Workflow step execution (inline vs queue dispatch) is now decided entirely by
73
+ the step's function `inline` flag — the workflow-level / run-level `inline`
74
+ meta no longer participates in per-step dispatch.
75
+ - Steps default to **inline**, so a normally-started (queue-backed) workflow
76
+ runs its whole chain in one orchestrator pass instead of one queue
77
+ round-trip per step.
78
+ - A function marked `inline: false` is dispatched via the queue (its own
79
+ worker, retry isolation). When `inline: false` but no `queueService` is
80
+ configured, the step falls back to inline and emits a `logger.warn` instead
81
+ of silently swallowing the misconfiguration.
82
+ - Removed the now-unused workflow-level `inline` from `WorkflowsMeta` /
83
+ `WorkflowRuntimeMeta`, the inspector's workflow extraction, the DSL→graph
84
+ converter, and the deploy analyzer / service inference (which now key off
85
+ the per-function flag). Run-level `inline` is retained: it still controls
86
+ whether a whole run executes in-process without queue infrastructure.
87
+
1
88
  ## 0.12.29
2
89
 
3
90
  ### Patch Changes
@@ -1,11 +1,11 @@
1
1
  export type Private<T> = T & {
2
- readonly __classification__: 'private';
2
+ readonly __classification__?: 'private';
3
3
  };
4
4
  export type Pii<T> = T & {
5
- readonly __classification__: 'pii';
5
+ readonly __classification__?: 'pii';
6
6
  };
7
7
  export type Secret<T> = T & {
8
- readonly __classification__: 'secret';
8
+ readonly __classification__?: 'secret';
9
9
  };
10
10
  export type Classification = 'public' | 'private' | 'pii' | 'secret';
11
11
  export type AnonymizeStrategy = 'fake:email' | 'fake:name' | 'hash' | 'keep' | null;
@@ -306,6 +306,20 @@ export class LocalMetaService {
306
306
  readEmailFile(`templates/${templateName}.subject.txt`),
307
307
  readEmailFile(`templates/${templateName}.text.txt`),
308
308
  ]);
309
+ const missing = [
310
+ ...(themeRaw ? [] : ['theme']),
311
+ ...(localeRaw ? [] : ['locale']),
312
+ ...(templateHtml ? [] : ['html']),
313
+ ...(templateSubject ? [] : ['subject']),
314
+ ...(templateText ? [] : ['text']),
315
+ ];
316
+ if (missing.length > 0) {
317
+ // Diagnostic for the intermittent "Missing source files" report: logs which
318
+ // process and which resolved baseDir saw the gap. Different pids across
319
+ // failures => multiple runtime workers with divergent cached `src`; same pid
320
+ // + transient absence => files being rewritten under us (e.g. regeneration).
321
+ console.warn(`[email-assets] pid=${process.pid} template=${templateName} locale=${locale} baseDir=${baseDir} missing=${missing.join(',')}`);
322
+ }
309
323
  return {
310
324
  theme: themeRaw ? JSON.parse(themeRaw) : {},
311
325
  strings: localeRaw
@@ -318,13 +332,7 @@ export class LocalMetaService {
318
332
  html: templateHtml ?? '',
319
333
  subject: templateSubject ?? '',
320
334
  text: templateText ?? '',
321
- missing: [
322
- ...(themeRaw ? [] : ['theme']),
323
- ...(localeRaw ? [] : ['locale']),
324
- ...(templateHtml ? [] : ['html']),
325
- ...(templateSubject ? [] : ['subject']),
326
- ...(templateText ? [] : ['text']),
327
- ],
335
+ missing,
328
336
  };
329
337
  }
330
338
  async getServicesMeta() {
@@ -28,7 +28,7 @@ import type { CredentialService } from '../services/credential-service.js';
28
28
  import type { EmailService } from '../services/email-service.js';
29
29
  import type { MetaService } from '../services/meta-service.js';
30
30
  import type { SessionStore } from '../services/session-store.js';
31
- import type { AuditDurability, AuditService } from '../services/audit-service.js';
31
+ import type { AuditDurability, AuditLog, AuditService } from '../services/audit-service.js';
32
32
  export type PikkuWiringTypes = 'http' | 'scheduler' | 'trigger' | 'channel' | 'rpc' | 'queue' | 'mcp' | 'cli' | 'workflow' | 'agent' | 'gateway';
33
33
  export interface FunctionServicesMeta {
34
34
  optimized: boolean;
@@ -196,6 +196,14 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
196
196
  metaService?: MetaService;
197
197
  /** Audit service for durable or staged audit event capture */
198
198
  audit?: AuditService;
199
+ /**
200
+ * Per-invocation audit log. Typically created in `createWireServices` via
201
+ * `createInvocationAudit(audit, wire)` and returned as a wire service so the
202
+ * runner flushes its buffer (via `close()`) when the invocation ends. Distinct
203
+ * from `audit` (the durable sink): this is the request-scoped buffer that
204
+ * writes into it.
205
+ */
206
+ auditLog?: AuditLog;
199
207
  /** Session store for persisting user sessions keyed by pikkuUserId */
200
208
  sessionStore?: SessionStore;
201
209
  }
@@ -4,6 +4,12 @@ export type CoreSecret<T = unknown> = {
4
4
  description?: string;
5
5
  secretId: string;
6
6
  schema: T;
7
+ /**
8
+ * Optional rotation cadence for this secret, e.g. '1d', '30day', '1w'.
9
+ * Stored in the generated secrets metadata so consumers can tell when a
10
+ * secret was last updated and whether it is due for rotation.
11
+ */
12
+ rotationPeriod?: string;
7
13
  };
8
14
  export type OAuth2CredentialConfig = {
9
15
  tokenSecretId: string;
@@ -20,6 +26,7 @@ export type SecretDefinitionMeta = {
20
26
  secretId: string;
21
27
  schema?: Record<string, unknown> | string;
22
28
  oauth2?: OAuth2CredentialConfig;
29
+ rotationPeriod?: string;
23
30
  sourceFile?: string;
24
31
  };
25
32
  export type SecretDefinitionsMeta = Record<string, SecretDefinitionMeta>;
@@ -33,6 +33,7 @@ export function validateAndBuildSecretDefinitionsMeta(definitions, schemaLookup)
33
33
  secretId: def.secretId,
34
34
  schema: def.schema,
35
35
  oauth2: def.oauth2,
36
+ rotationPeriod: def.rotationPeriod,
36
37
  sourceFile: def.sourceFile,
37
38
  };
38
39
  }
@@ -47,6 +48,7 @@ export function validateAndBuildSecretDefinitionsMeta(definitions, schemaLookup)
47
48
  secretId: def.secretId,
48
49
  schema: def.schema,
49
50
  oauth2: def.oauth2,
51
+ rotationPeriod: def.rotationPeriod,
50
52
  sourceFile: def.sourceFile,
51
53
  };
52
54
  }
@@ -27,7 +27,11 @@ export type WorkflowWireDoInline = <T>(stepName: string, fn: () => Promise<T> |
27
27
  */
28
28
  export type WorkflowWireSleep = (stepName: string, duration: string) => Promise<void>;
29
29
  /**
30
- * Type signature for workflow.suspend() - used by inspector
30
+ * Type signature for workflow.suspend() - used by inspector.
31
+ * `reason` is both the human-readable message stored on the suspended run and
32
+ * the suspend point's stable identity (used raw as the durable step name), so a
33
+ * workflow can have multiple independent suspends — including dynamic reasons in
34
+ * loops, like dynamic `do()` step names.
31
35
  */
32
36
  export type WorkflowWireSuspend = (reason: string) => Promise<void>;
33
37
  /**
@@ -359,7 +359,7 @@ export async function executeGraphStep(workflowService, rpcService, runId, stepI
359
359
  parentRunId: runId,
360
360
  parentStepId: stepId,
361
361
  };
362
- const shouldInline = subWorkflowMeta.inline || !getSingletonServices()?.queueService;
362
+ const shouldInline = !getSingletonServices()?.queueService;
363
363
  const { runId: childRunId } = await workflowService.startWorkflow(rpcName, data, childWire, rpcService, { inline: shouldInline });
364
364
  await workflowService.setStepChildRunId(stepId, childRunId);
365
365
  if (shouldInline) {
@@ -335,6 +335,17 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
335
335
  private rpcStep;
336
336
  private inlineStep;
337
337
  private sleepStep;
338
+ /**
339
+ * Derive the durable step name for a suspend point from its `reason`, so each
340
+ * distinct reason is its own step row — letting one workflow have multiple
341
+ * independent suspends (e.g. wait-for-build, then wait-for-approval), and
342
+ * supporting dynamic reasons in loops (`suspend(`Wait for ${i}`)`) exactly
343
+ * like dynamic `do()` step names. The reason is used raw (it's just a text
344
+ * step name), only namespaced so it can't collide with a `do`/`sleep` step of
345
+ * the same name. Like `do()` / `sleep()`, the reason is the step's stable
346
+ * identity: it MUST be derived deterministically so it's the same every time
347
+ * the workflow replays through that point.
348
+ */
338
349
  private getSuspendStepName;
339
350
  private suspendStep;
340
351
  createWorkflowWire(name: string, runId: string, rpcService: any, addonNamespace?: string | null): PikkuWorkflowWire;
@@ -484,16 +484,25 @@ export class PikkuWorkflowService {
484
484
  * through to the inline execution path.
485
485
  */
486
486
  async dispatchStep(runId, stepName, rpcName, data, stepOptions) {
487
- if (!getSingletonServices()?.queueService) {
488
- return false;
489
- }
490
- // Functions default to inline execution. Only dispatch via queue when the
491
- // function explicitly sets inline: false.
487
+ // Step execution is decided purely by the function's `inline` flag (default
488
+ // true). Only a function explicitly marked `inline: false` dispatches via
489
+ // the queue. The run-level inline state is intentionally NOT consulted
490
+ // here: default steps run inline even inside a queue-dispatched run, so a
491
+ // normally-started workflow executes its steps in one orchestrator-worker
492
+ // pass instead of one queue round-trip per step.
492
493
  const functionsMeta = pikkuState(null, 'function', 'meta');
493
494
  const rpcFuncId = pikkuState(null, 'rpc', 'meta')[rpcName];
494
495
  const rpcMeta = typeof rpcFuncId === 'string' ? functionsMeta[rpcFuncId] : undefined;
495
496
  const forceQueue = rpcMeta?.inline === false;
496
- if (!forceQueue && this.isInline(runId)) {
497
+ if (!forceQueue) {
498
+ return false;
499
+ }
500
+ // The function opted out of inline execution (`inline: false`) but no queue
501
+ // service is configured to dispatch it. Fall back to inline so the workflow
502
+ // still progresses, but warn loudly — silently swallowing this hides a real
503
+ // misconfiguration (the step won't get its own worker/retry isolation).
504
+ if (!getSingletonServices()?.queueService) {
505
+ getSingletonServices()?.logger.warn(`Workflow step '${stepName}' (function '${rpcName}') is marked 'inline: false' but no queue service is configured — running it inline instead of dispatching to a queue.`);
497
506
  return false;
498
507
  }
499
508
  const retries = stepOptions?.retries ?? 0;
@@ -553,9 +562,7 @@ export class PikkuWorkflowService {
553
562
  }
554
563
  if (workflowMeta.source === 'graph' ||
555
564
  workflowMeta.source === 'dynamic-workflow') {
556
- const shouldInline = options?.inline ||
557
- workflowMeta.inline ||
558
- !getSingletonServices()?.queueService;
565
+ const shouldInline = options?.inline || !getSingletonServices()?.queueService;
559
566
  return runWorkflowGraph(this, name, input, rpcService, shouldInline, options?.startNode, wire, workflowMeta);
560
567
  }
561
568
  // DSL workflow - check registration exists
@@ -567,9 +574,7 @@ export class PikkuWorkflowService {
567
574
  if (!workflowMeta.graphHash) {
568
575
  throw new Error(`Missing workflow graphHash for '${name}'`);
569
576
  }
570
- const shouldInline = options?.inline ||
571
- workflowMeta.inline ||
572
- !getSingletonServices()?.queueService;
577
+ const shouldInline = options?.inline || !getSingletonServices()?.queueService;
573
578
  const runId = await this.createRun(name, input, shouldInline, workflowMeta.graphHash, wire, {
574
579
  deterministic: workflowMeta.deterministic,
575
580
  plannedSteps: workflowMeta.plannedSteps,
@@ -808,7 +813,7 @@ export class PikkuWorkflowService {
808
813
  parentRunId: runId,
809
814
  parentStepId: stepState.stepId,
810
815
  };
811
- const shouldInline = subWorkflowMeta.inline || !getSingletonServices()?.queueService;
816
+ const shouldInline = !getSingletonServices()?.queueService;
812
817
  const { runId: childRunId } = await this.startWorkflow(rpcName, data, childWire, rpcService, { inline: shouldInline });
813
818
  await this.setStepChildRunId(stepState.stepId, childRunId);
814
819
  if (shouldInline) {
@@ -1115,26 +1120,34 @@ export class PikkuWorkflowService {
1115
1120
  await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(duration)));
1116
1121
  await this.setStepResult(stepState.stepId, null);
1117
1122
  }
1123
+ /**
1124
+ * Derive the durable step name for a suspend point from its `reason`, so each
1125
+ * distinct reason is its own step row — letting one workflow have multiple
1126
+ * independent suspends (e.g. wait-for-build, then wait-for-approval), and
1127
+ * supporting dynamic reasons in loops (`suspend(`Wait for ${i}`)`) exactly
1128
+ * like dynamic `do()` step names. The reason is used raw (it's just a text
1129
+ * step name), only namespaced so it can't collide with a `do`/`sleep` step of
1130
+ * the same name. Like `do()` / `sleep()`, the reason is the step's stable
1131
+ * identity: it MUST be derived deterministically so it's the same every time
1132
+ * the workflow replays through that point.
1133
+ */
1118
1134
  getSuspendStepName(reason) {
1119
- if (!reason) {
1120
- return '__workflow_suspend';
1121
- }
1122
- return '__workflow_suspend';
1135
+ return `__workflow_suspend:${reason}`;
1123
1136
  }
1124
1137
  async suspendStep(runId, reason) {
1125
- const stepName = this.getSuspendStepName(reason);
1126
- await this.withStepLock(runId, stepName, async () => {
1138
+ const suspendStepName = this.getSuspendStepName(reason);
1139
+ await this.withStepLock(runId, suspendStepName, async () => {
1127
1140
  let stepState;
1128
1141
  try {
1129
- stepState = await this.getStepState(runId, stepName);
1142
+ stepState = await this.getStepState(runId, suspendStepName);
1130
1143
  }
1131
1144
  catch {
1132
- stepState = await this.insertStepState(runId, stepName, 'pikkuWorkflowSuspend', {
1145
+ stepState = await this.insertStepState(runId, suspendStepName, 'pikkuWorkflowSuspend', {
1133
1146
  reason,
1134
1147
  });
1135
1148
  }
1136
1149
  if (!stepState.stepId) {
1137
- stepState = await this.insertStepState(runId, stepName, 'pikkuWorkflowSuspend', {
1150
+ stepState = await this.insertStepState(runId, suspendStepName, 'pikkuWorkflowSuspend', {
1138
1151
  reason,
1139
1152
  });
1140
1153
  }
@@ -1175,7 +1188,8 @@ export class PikkuWorkflowService {
1175
1188
  await this.sleepStep(runId, stepName, getDurationInMilliseconds(duration));
1176
1189
  },
1177
1190
  suspend: async (reason) => {
1178
- await this.suspendStep(runId, reason || 'Workflow suspended');
1191
+ this.verifyStepName(reason);
1192
+ await this.suspendStep(runId, reason);
1179
1193
  },
1180
1194
  };
1181
1195
  return workflowWire;
@@ -228,7 +228,6 @@ export type WorkflowsMeta = Record<string, CommonWireMeta & {
228
228
  steps: WorkflowStepMeta[];
229
229
  context?: WorkflowContext;
230
230
  dsl?: boolean;
231
- inline?: boolean;
232
231
  expose?: boolean;
233
232
  }>;
234
233
  /**
@@ -247,8 +246,6 @@ export interface WorkflowRuntimeMeta {
247
246
  description?: string;
248
247
  /** Tags for organization */
249
248
  tags?: string[];
250
- /** If true, workflow always executes inline without queues */
251
- inline?: boolean;
252
249
  /** Serialized nodes */
253
250
  nodes?: Record<string, any>;
254
251
  /** Entry node IDs for graph workflows (computed at build time) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.29",
3
+ "version": "0.12.31",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -1,6 +1,13 @@
1
- export type Private<T> = T & { readonly __classification__: 'private' }
2
- export type Pii<T> = T & { readonly __classification__: 'pii' }
3
- export type Secret<T> = T & { readonly __classification__: 'secret' }
1
+ // The `__classification__` marker is OPTIONAL on purpose. A required property
2
+ // would make a plain value (e.g. `string`) unassignable to a branded column
3
+ // (`Private<string>`), which breaks ordinary Kysely query operands
4
+ // `where('email', '=', someString)`, inserts, and `.set(...)`. Making it
5
+ // optional keeps the brand structurally present (so the inspector's PKU910
6
+ // output check still detects it) while allowing plain values to flow IN.
7
+ // See `findPiiPaths` in @pikku/inspector, which reads the level union-aware.
8
+ export type Private<T> = T & { readonly __classification__?: 'private' }
9
+ export type Pii<T> = T & { readonly __classification__?: 'pii' }
10
+ export type Secret<T> = T & { readonly __classification__?: 'secret' }
4
11
 
5
12
  export type Classification = 'public' | 'private' | 'pii' | 'secret'
6
13
  export type AnonymizeStrategy =
@@ -562,6 +562,24 @@ export class LocalMetaService implements MetaService {
562
562
  readEmailFile(`templates/${templateName}.text.txt`),
563
563
  ])
564
564
 
565
+ const missing = [
566
+ ...(themeRaw ? [] : ['theme']),
567
+ ...(localeRaw ? [] : ['locale']),
568
+ ...(templateHtml ? [] : ['html']),
569
+ ...(templateSubject ? [] : ['subject']),
570
+ ...(templateText ? [] : ['text']),
571
+ ]
572
+
573
+ if (missing.length > 0) {
574
+ // Diagnostic for the intermittent "Missing source files" report: logs which
575
+ // process and which resolved baseDir saw the gap. Different pids across
576
+ // failures => multiple runtime workers with divergent cached `src`; same pid
577
+ // + transient absence => files being rewritten under us (e.g. regeneration).
578
+ console.warn(
579
+ `[email-assets] pid=${process.pid} template=${templateName} locale=${locale} baseDir=${baseDir} missing=${missing.join(',')}`
580
+ )
581
+ }
582
+
565
583
  return {
566
584
  theme: themeRaw ? (JSON.parse(themeRaw) as Record<string, unknown>) : {},
567
585
  strings: localeRaw
@@ -574,13 +592,7 @@ export class LocalMetaService implements MetaService {
574
592
  html: templateHtml ?? '',
575
593
  subject: templateSubject ?? '',
576
594
  text: templateText ?? '',
577
- missing: [
578
- ...(themeRaw ? [] : ['theme']),
579
- ...(localeRaw ? [] : ['locale']),
580
- ...(templateHtml ? [] : ['html']),
581
- ...(templateSubject ? [] : ['subject']),
582
- ...(templateText ? [] : ['text']),
583
- ],
595
+ missing,
584
596
  }
585
597
  }
586
598
 
@@ -40,6 +40,7 @@ import type { MetaService } from '../services/meta-service.js'
40
40
  import type { SessionStore } from '../services/session-store.js'
41
41
  import type {
42
42
  AuditDurability,
43
+ AuditLog,
43
44
  AuditService,
44
45
  } from '../services/audit-service.js'
45
46
 
@@ -255,6 +256,14 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
255
256
  metaService?: MetaService
256
257
  /** Audit service for durable or staged audit event capture */
257
258
  audit?: AuditService
259
+ /**
260
+ * Per-invocation audit log. Typically created in `createWireServices` via
261
+ * `createInvocationAudit(audit, wire)` and returned as a wire service so the
262
+ * runner flushes its buffer (via `close()`) when the invocation ends. Distinct
263
+ * from `audit` (the durable sink): this is the request-scoped buffer that
264
+ * writes into it.
265
+ */
266
+ auditLog?: AuditLog
258
267
  /** Session store for persisting user sessions keyed by pikkuUserId */
259
268
  sessionStore?: SessionStore
260
269
  }
@@ -4,6 +4,12 @@ export type CoreSecret<T = unknown> = {
4
4
  description?: string
5
5
  secretId: string
6
6
  schema: T
7
+ /**
8
+ * Optional rotation cadence for this secret, e.g. '1d', '30day', '1w'.
9
+ * Stored in the generated secrets metadata so consumers can tell when a
10
+ * secret was last updated and whether it is due for rotation.
11
+ */
12
+ rotationPeriod?: string
7
13
  }
8
14
 
9
15
  export type OAuth2CredentialConfig = {
@@ -22,6 +28,7 @@ export type SecretDefinitionMeta = {
22
28
  secretId: string
23
29
  schema?: Record<string, unknown> | string
24
30
  oauth2?: OAuth2CredentialConfig
31
+ rotationPeriod?: string
25
32
  sourceFile?: string
26
33
  }
27
34
 
@@ -57,6 +57,7 @@ export function validateAndBuildSecretDefinitionsMeta(
57
57
  secretId: def.secretId,
58
58
  schema: def.schema,
59
59
  oauth2: def.oauth2,
60
+ rotationPeriod: def.rotationPeriod,
60
61
  sourceFile: def.sourceFile,
61
62
  }
62
63
  }
@@ -73,6 +74,7 @@ export function validateAndBuildSecretDefinitionsMeta(
73
74
  secretId: def.secretId,
74
75
  schema: def.schema,
75
76
  oauth2: def.oauth2,
77
+ rotationPeriod: def.rotationPeriod,
76
78
  sourceFile: def.sourceFile,
77
79
  }
78
80
  }
@@ -46,7 +46,11 @@ export type WorkflowWireSleep = (
46
46
  ) => Promise<void>
47
47
 
48
48
  /**
49
- * Type signature for workflow.suspend() - used by inspector
49
+ * Type signature for workflow.suspend() - used by inspector.
50
+ * `reason` is both the human-readable message stored on the suspended run and
51
+ * the suspend point's stable identity (used raw as the durable step name), so a
52
+ * workflow can have multiple independent suspends — including dynamic reasons in
53
+ * loops, like dynamic `do()` step names.
50
54
  */
51
55
  export type WorkflowWireSuspend = (reason: string) => Promise<void>
52
56
 
@@ -485,7 +485,7 @@ describe('graph-runner bugs', () => {
485
485
  delete metaState['testInlineRpcMissing']
486
486
  })
487
487
 
488
- test('graph workflow with inline: true in meta should run inline', async () => {
488
+ test('graph workflow started with the inline flag should run inline', async () => {
489
489
  const ws = new InMemoryWorkflowService()
490
490
  let executed = false
491
491
 
@@ -511,7 +511,6 @@ describe('graph-runner bugs', () => {
511
511
  name: 'testInlineMetaGraph',
512
512
  pikkuFuncId: 'testInlineMetaGraph',
513
513
  source: 'graph',
514
- inline: true,
515
514
  entryNodeIds: ['a'],
516
515
  graphHash: 'inline-meta-graph-hash',
517
516
  nodes: {
@@ -497,8 +497,7 @@ export async function executeGraphStep(
497
497
  parentRunId: runId,
498
498
  parentStepId: stepId,
499
499
  }
500
- const shouldInline =
501
- subWorkflowMeta.inline || !getSingletonServices()?.queueService
500
+ const shouldInline = !getSingletonServices()?.queueService
502
501
  const { runId: childRunId } = await workflowService.startWorkflow(
503
502
  rpcName,
504
503
  data,