@pikku/core 0.12.86 → 0.12.89

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 (49) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/dist/dev/hot-reload.d.ts +1 -1
  3. package/dist/dev/hot-reload.js +1 -1
  4. package/dist/types/core.types.d.ts +1 -1
  5. package/dist/types/index.d.ts +1 -1
  6. package/dist/types/index.js +1 -1
  7. package/dist/wirings/agent/agent-memory.d.ts +11 -0
  8. package/dist/wirings/agent/agent-memory.js +17 -2
  9. package/dist/wirings/agent/agent-stream.js +115 -91
  10. package/dist/wirings/agent/agent.types.d.ts +9 -8
  11. package/dist/wirings/rpc/rpc-runner.js +1 -1
  12. package/dist/wirings/variable/validate-variable-definitions.js +2 -2
  13. package/dist/wirings/variable/variable.types.d.ts +13 -7
  14. package/dist/wirings/workflow/pikku-scenario-service.d.ts +2 -2
  15. package/dist/wirings/workflow/pikku-workflow-service.d.ts +20 -6
  16. package/dist/wirings/workflow/pikku-workflow-service.js +29 -27
  17. package/dist/wirings/workflow/workflow-constants.d.ts +17 -0
  18. package/dist/wirings/workflow/workflow-constants.js +17 -0
  19. package/dist/wirings/workflow/workflow-recovery.d.ts +18 -1
  20. package/dist/wirings/workflow/workflow-recovery.js +30 -2
  21. package/dist/wirings/workflow/workflow-step-claim.d.ts +16 -0
  22. package/dist/wirings/workflow/workflow-step-claim.js +27 -0
  23. package/knowledge/decisions/security/a-scaffold-flag-says-a-surface-exists-not-who-may-call-it.md +47 -0
  24. package/knowledge/decisions/security/index.md +2 -1
  25. package/knowledge/decisions/security/scaffold-features-are-authenticated-unless-opted-out.md +6 -0
  26. package/package.json +1 -1
  27. package/src/dev/hot-reload.ts +1 -1
  28. package/src/types/core.types.ts +1 -1
  29. package/src/types/index.ts +24 -1
  30. package/src/wirings/agent/agent-memory.test.ts +73 -0
  31. package/src/wirings/agent/agent-memory.ts +16 -2
  32. package/src/wirings/agent/agent-middleware.types.test.ts +41 -0
  33. package/src/wirings/agent/agent-stream-delegate.test.ts +381 -0
  34. package/src/wirings/agent/agent-stream.ts +158 -78
  35. package/src/wirings/agent/agent.types.ts +9 -8
  36. package/src/wirings/rpc/rpc-runner.test.ts +6 -1
  37. package/src/wirings/rpc/rpc-runner.ts +1 -1
  38. package/src/wirings/variable/validate-variable-definitions.test.ts +11 -10
  39. package/src/wirings/variable/validate-variable-definitions.ts +2 -2
  40. package/src/wirings/variable/variable.types.ts +13 -7
  41. package/src/wirings/workflow/pikku-scenario-service.ts +29 -6
  42. package/src/wirings/workflow/pikku-workflow-service.ts +34 -27
  43. package/src/wirings/workflow/workflow-constants.ts +19 -0
  44. package/src/wirings/workflow/workflow-recovery.ts +31 -1
  45. package/src/wirings/workflow/workflow-stalled-recovery.test.ts +46 -0
  46. package/src/wirings/workflow/workflow-step-claim.ts +46 -0
  47. package/src/wirings/workflow/workflow-terminal-run-guard.test.ts +105 -0
  48. package/tsconfig.tsbuildinfo +1 -1
  49. package/tsconfig.type-tests.json +2 -1
@@ -105,12 +105,12 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
105
105
  *
106
106
  * Returns nothing by default so a store that cannot express the query keeps
107
107
  * working unchanged — and, because it does not opt in, gains no re-dispatches
108
- * either. A store must have an atomic `withStepLock` before overriding this,
109
- * or no concurrency for one to exclude: the relay makes duplicate dispatch
110
- * routine, and the claim in `executeWorkflowStepInner` is what keeps a
111
- * duplicate from becoming a second execution. `kysely-postgres` and
112
- * `kysely-mysql` qualify on the lock, `in-memory` on being inline and
113
- * single-process; `mongodb` and `kysely-sqlite` qualify on neither.
108
+ * either. A store must have an atomic `claimStepForExecution` before
109
+ * overriding this, or no concurrency for one to exclude: the relay makes
110
+ * duplicate dispatch routine, and the claim is what keeps a duplicate from
111
+ * becoming a second execution. Every `@pikku/kysely` dialect qualifies on its
112
+ * status-guarded claim, `in-memory` on being inline and single-process;
113
+ * `mongodb` still qualifies on neither.
114
114
  */
115
115
  protected findUndispatchedSteps(_before: Date, _limit: number): Promise<Array<{
116
116
  runId: string;
@@ -176,6 +176,20 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
176
176
  protected onChildWorkflowFailed(childRun: WorkflowRun, error: Error): Promise<void>;
177
177
  private runVersionMismatchFallback;
178
178
  executeWorkflowStep(runId: string, stepName: string, rpcName: string, data: any, rpcService: PikkuRPC): Promise<void>;
179
+ /**
180
+ * Take sole ownership of a step before it runs, returning the state to run
181
+ * under — or `null` when another dispatch already owns it.
182
+ *
183
+ * Dispatch is at-least-once by design: the relay re-dispatches steps it
184
+ * believes were dropped, and a queue can redeliver a job it already handed
185
+ * out. This is the one place that keeps a duplicate dispatch from becoming a
186
+ * second execution of a side-effecting step, so it is only as strong as the
187
+ * exclusion it is built on — and `withStepLock` excludes nothing unless the
188
+ * store backs it with a real primitive. A store able to express the decision
189
+ * as one conditional write should override this rather than reach for a lock,
190
+ * which is what `@pikku/kysely` does with a status-guarded `UPDATE`.
191
+ */
192
+ protected claimStepForExecution(runId: string, stepName: string, rpcName: string): Promise<StepState | null>;
179
193
  private executeWorkflowStepInner;
180
194
  orchestrateWorkflow(runId: string, rpcService: PikkuRPC): Promise<void>;
181
195
  private verifyQueueService;
@@ -8,14 +8,15 @@ import { RPCNotFoundError } from '../rpc/rpc-runner.js';
8
8
  import { deriveInvocationId } from './workflow-invocation-id.js';
9
9
  import { approvalDeciderFrom } from './workflow-approval-policy.js';
10
10
  import { buildRunTimeline, reconstructStateAt, } from './run-timeline.js';
11
- import { DEFAULT_STEP_RETRIES, WORKFLOW_CHILD_POLL_MAX_MS, WORKFLOW_END_STATES, WORKFLOW_POLL_FACTOR, WORKFLOW_POLL_MIN_MS, WORKFLOW_TERMINAL_STATES, } from './workflow-constants.js';
12
- import { WorkflowAsyncException, WorkflowCancelledException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotFoundError, WorkflowStepFunctionMismatchError, WorkflowStepNameNotString, WorkflowSuspendedException, } from './workflow-errors.js';
11
+ import { DEFAULT_STEP_RETRIES, WORKFLOW_CHILD_POLL_MAX_MS, WORKFLOW_END_STATES, WORKFLOW_POLL_FACTOR, WORKFLOW_POLL_MIN_MS, WORKFLOW_TERMINAL_STATES, isRunSettled, } from './workflow-constants.js';
12
+ import { WorkflowAsyncException, WorkflowCancelledException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotFoundError, WorkflowStepNameNotString, WorkflowSuspendedException, } from './workflow-errors.js';
13
13
  import { resolveWorkflowMeta } from './workflow-meta-resolver.js';
14
14
  import { jobGroupFor, orchestratorQueueName, resolveWorkflowConfig, stepJobOptions, stepWorkerQueueName, } from './workflow-queue-routing.js';
15
15
  import { wireWorkflowQueueWorkers } from './workflow-queue-wiring.js';
16
16
  import { approvalStepNameFor, evaluateApprovalStep, recordApprovalDecision, } from './workflow-approval.js';
17
17
  import { auditApprovalDecision } from './workflow-approval-audit.js';
18
18
  import { recordSuspension, suspendStepNameFor } from './workflow-suspend.js';
19
+ import { claimStepByReadThenWrite } from './workflow-step-claim.js';
19
20
  import { RedispatchBackoff, sweepStalledRuns, sweepUndispatchedSteps, } from './workflow-recovery.js';
20
21
  export class PikkuWorkflowService {
21
22
  runExtension;
@@ -261,12 +262,12 @@ export class PikkuWorkflowService {
261
262
  *
262
263
  * Returns nothing by default so a store that cannot express the query keeps
263
264
  * working unchanged — and, because it does not opt in, gains no re-dispatches
264
- * either. A store must have an atomic `withStepLock` before overriding this,
265
- * or no concurrency for one to exclude: the relay makes duplicate dispatch
266
- * routine, and the claim in `executeWorkflowStepInner` is what keeps a
267
- * duplicate from becoming a second execution. `kysely-postgres` and
268
- * `kysely-mysql` qualify on the lock, `in-memory` on being inline and
269
- * single-process; `mongodb` and `kysely-sqlite` qualify on neither.
265
+ * either. A store must have an atomic `claimStepForExecution` before
266
+ * overriding this, or no concurrency for one to exclude: the relay makes
267
+ * duplicate dispatch routine, and the claim is what keeps a duplicate from
268
+ * becoming a second execution. Every `@pikku/kysely` dialect qualifies on its
269
+ * status-guarded claim, `in-memory` on being inline and single-process;
270
+ * `mongodb` still qualifies on neither.
270
271
  */
271
272
  async findUndispatchedSteps(_before, _limit) {
272
273
  return [];
@@ -283,7 +284,7 @@ export class PikkuWorkflowService {
283
284
  * scheduled task at whatever interval suits the workload.
284
285
  */
285
286
  async recoverStalledRuns(options) {
286
- return sweepStalledRuns((before, limit) => this.findStalledRunIds(before, limit), options, this.sweepDeps);
287
+ return sweepStalledRuns((before, limit) => this.findStalledRunIds(before, limit), this.redispatchBackoff, options, this.sweepDeps);
287
288
  }
288
289
  /**
289
290
  * Re-drive steps whose dispatch was lost. Not self-starting — call it from a
@@ -545,6 +546,8 @@ export class PikkuWorkflowService {
545
546
  if (!run) {
546
547
  throw new WorkflowRunNotFoundError(runId);
547
548
  }
549
+ if (isRunSettled(run.status))
550
+ return;
548
551
  const resolved = resolveWorkflowMeta(run.workflow);
549
552
  const workflowMeta = resolved?.meta;
550
553
  const pkgName = resolved?.packageName ?? null;
@@ -697,25 +700,24 @@ export class PikkuWorkflowService {
697
700
  this.exitExecution(runId);
698
701
  }
699
702
  }
703
+ /**
704
+ * Take sole ownership of a step before it runs, returning the state to run
705
+ * under — or `null` when another dispatch already owns it.
706
+ *
707
+ * Dispatch is at-least-once by design: the relay re-dispatches steps it
708
+ * believes were dropped, and a queue can redeliver a job it already handed
709
+ * out. This is the one place that keeps a duplicate dispatch from becoming a
710
+ * second execution of a side-effecting step, so it is only as strong as the
711
+ * exclusion it is built on — and `withStepLock` excludes nothing unless the
712
+ * store backs it with a real primitive. A store able to express the decision
713
+ * as one conditional write should override this rather than reach for a lock,
714
+ * which is what `@pikku/kysely` does with a status-guarded `UPDATE`.
715
+ */
716
+ async claimStepForExecution(runId, stepName, rpcName) {
717
+ return this.withStepLock(runId, stepName, () => claimStepByReadThenWrite(this, runId, stepName, rpcName));
718
+ }
700
719
  async executeWorkflowStepInner(runId, stepName, rpcName, data, rpcService) {
701
- const claimed = await this.withStepLock(runId, stepName, async () => {
702
- const stepState = await this.getStepState(runId, stepName);
703
- // knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
704
- if (stepState.rpcName !== undefined &&
705
- stepState.rpcName !== (rpcName ?? null)) {
706
- throw new WorkflowStepFunctionMismatchError(runId, stepName);
707
- }
708
- if (stepState.status === 'succeeded' || stepState.status === 'running') {
709
- return null;
710
- }
711
- if (stepState.status === 'failed') {
712
- return this.createRetryAttempt(stepState.stepId, 'running');
713
- }
714
- if (stepState.status === 'pending' || stepState.status === 'scheduled') {
715
- await this.setStepRunning(stepState.stepId);
716
- }
717
- return stepState;
718
- });
720
+ const claimed = await this.claimStepForExecution(runId, stepName, rpcName);
719
721
  if (!claimed) {
720
722
  return;
721
723
  }
@@ -3,6 +3,23 @@ export declare const DEFAULT_STEP_RETRIES = 5;
3
3
  export declare const WORKFLOW_END_STATES: ReadonlySet<string>;
4
4
  /** Statuses a run cannot leave at all. */
5
5
  export declare const WORKFLOW_TERMINAL_STATES: ReadonlySet<string>;
6
+ /**
7
+ * True for a run that will never move again, whatever arrives for it.
8
+ *
9
+ * Worth checking before doing anything with an orchestrator message, because
10
+ * such a message is routine rather than exceptional: the queue is
11
+ * at-least-once, the relay re-dispatches on purpose, and a run can settle
12
+ * while a message for it is still in flight. Replaying one is not free —
13
+ * `runWorkflowJob` takes the run lock and re-enters the workflow body, and a
14
+ * body re-entered after its run failed can park on a wait that nothing will
15
+ * ever satisfy, holding the lock and the connection under it until something
16
+ * external gives up. Every leaked advisory lock seen in production traced back
17
+ * to that: a granted lock, an idle session, and a run already `failed`.
18
+ *
19
+ * Note `suspended` is deliberately absent. It ends a run's *current* pass but
20
+ * not the run, which resumes when its approval or signal arrives.
21
+ */
22
+ export declare const isRunSettled: (status: string) => boolean;
6
23
  export declare const WORKFLOW_POLL_MIN_MS = 10;
7
24
  export declare const WORKFLOW_POLL_FACTOR = 1.6;
8
25
  export declare const WORKFLOW_CHILD_POLL_MAX_MS = 500;
@@ -12,6 +12,23 @@ export const WORKFLOW_TERMINAL_STATES = new Set([
12
12
  'failed',
13
13
  'cancelled',
14
14
  ]);
15
+ /**
16
+ * True for a run that will never move again, whatever arrives for it.
17
+ *
18
+ * Worth checking before doing anything with an orchestrator message, because
19
+ * such a message is routine rather than exceptional: the queue is
20
+ * at-least-once, the relay re-dispatches on purpose, and a run can settle
21
+ * while a message for it is still in flight. Replaying one is not free —
22
+ * `runWorkflowJob` takes the run lock and re-enters the workflow body, and a
23
+ * body re-entered after its run failed can park on a wait that nothing will
24
+ * ever satisfy, holding the lock and the connection under it until something
25
+ * external gives up. Every leaked advisory lock seen in production traced back
26
+ * to that: a granted lock, an idle session, and a run already `failed`.
27
+ *
28
+ * Note `suspended` is deliberately absent. It ends a run's *current* pass but
29
+ * not the run, which resumes when its approval or signal arrives.
30
+ */
31
+ export const isRunSettled = (status) => WORKFLOW_TERMINAL_STATES.has(status);
15
32
  export const WORKFLOW_POLL_MIN_MS = 10;
16
33
  export const WORKFLOW_POLL_FACTOR = 1.6;
17
34
  export const WORKFLOW_CHILD_POLL_MAX_MS = 500;
@@ -7,6 +7,15 @@ import type { Logger } from '../../services/logger.js';
7
7
  * owed a job, so holding off a single step while resuming its run would
8
8
  * suppress nothing.
9
9
  *
10
+ * For the same reason one instance is shared by every sweep rather than kept
11
+ * per sweep. The record is of the action, not of the signal that prompted it:
12
+ * a stalled run and an undispatched step are different observations, but both
13
+ * are answered by the one orchestrator message, so a run the relay re-drove a
14
+ * moment ago gains nothing from the stalled sweep re-driving it again. Sharing
15
+ * is what makes the guarantee a message-per-run-per-window instead of one per
16
+ * sweep, and no recovery is lost by it: whichever sweep gets there first
17
+ * performs the identical re-drive, and the cap keeps the delay at 10m.
18
+ *
10
19
  * Losing this on restart costs extra dispatches, never correctness.
11
20
  */
12
21
  export declare class RedispatchBackoff {
@@ -34,8 +43,16 @@ type SweepDeps = {
34
43
  * actually stuck costs an orchestration pass and changes nothing. That
35
44
  * idempotence is what makes an idle-time heuristic safe here; a run that is
36
45
  * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
46
+ *
47
+ * Idempotent is not free, though: a run stays stalled until something clears
48
+ * the reason it stalled, so an unconditional sweep re-queues the same runs on
49
+ * every tick forever. That is how a handful of wedged runs became a queue of
50
+ * thousands that could not drain — each pass added work the previous pass had
51
+ * not finished. The same per-run backoff the relay uses bounds it: a run is
52
+ * re-driven, then held off for a doubling delay, so a sweep costs at most one
53
+ * message per run per window rather than one per run per tick.
37
54
  */
38
- export declare const sweepStalledRuns: (findStalledRunIds: (before: Date, limit: number) => Promise<string[]>, options: {
55
+ export declare const sweepStalledRuns: (findStalledRunIds: (before: Date, limit: number) => Promise<string[]>, backoff: RedispatchBackoff, options: {
39
56
  stalledAfterMs?: number;
40
57
  limit?: number;
41
58
  } | undefined, deps: SweepDeps) => Promise<{
@@ -7,6 +7,15 @@ import { DEFAULT_STALLED_RUN_LIMIT, DEFAULT_STALLED_RUN_MS, DEFAULT_UNDISPATCHED
7
7
  * owed a job, so holding off a single step while resuming its run would
8
8
  * suppress nothing.
9
9
  *
10
+ * For the same reason one instance is shared by every sweep rather than kept
11
+ * per sweep. The record is of the action, not of the signal that prompted it:
12
+ * a stalled run and an undispatched step are different observations, but both
13
+ * are answered by the one orchestrator message, so a run the relay re-drove a
14
+ * moment ago gains nothing from the stalled sweep re-driving it again. Sharing
15
+ * is what makes the guarantee a message-per-run-per-window instead of one per
16
+ * sweep, and no recovery is lost by it: whichever sweep gets there first
17
+ * performs the identical re-drive, and the cap keeps the delay at 10m.
18
+ *
10
19
  * Losing this on restart costs extra dispatches, never correctness.
11
20
  */
12
21
  export class RedispatchBackoff {
@@ -61,10 +70,29 @@ const resumeEach = async (runIds, { resume, logger }, failure) => {
61
70
  * actually stuck costs an orchestration pass and changes nothing. That
62
71
  * idempotence is what makes an idle-time heuristic safe here; a run that is
63
72
  * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
73
+ *
74
+ * Idempotent is not free, though: a run stays stalled until something clears
75
+ * the reason it stalled, so an unconditional sweep re-queues the same runs on
76
+ * every tick forever. That is how a handful of wedged runs became a queue of
77
+ * thousands that could not drain — each pass added work the previous pass had
78
+ * not finished. The same per-run backoff the relay uses bounds it: a run is
79
+ * re-driven, then held off for a doubling delay, so a sweep costs at most one
80
+ * message per run per window rather than one per run per tick.
64
81
  */
65
- export const sweepStalledRuns = async (findStalledRunIds, options, deps) => {
82
+ export const sweepStalledRuns = async (findStalledRunIds, backoff, options, deps) => {
66
83
  const before = new Date(Date.now() - (options?.stalledAfterMs ?? DEFAULT_STALLED_RUN_MS));
67
- const runIds = await findStalledRunIds(before, options?.limit ?? DEFAULT_STALLED_RUN_LIMIT);
84
+ const found = await findStalledRunIds(before, options?.limit ?? DEFAULT_STALLED_RUN_LIMIT);
85
+ const now = Date.now();
86
+ const runIds = [];
87
+ for (const runId of found) {
88
+ if (!backoff.isEligible(runId, now))
89
+ continue;
90
+ // Noted before the resume, not after: a run whose resume throws is exactly
91
+ // the run most likely to still be here next tick, and re-driving it every
92
+ // tick is the amplification this backoff exists to stop.
93
+ backoff.note(runId, now);
94
+ runIds.push(runId);
95
+ }
68
96
  return {
69
97
  resumed: await resumeEach(runIds, deps, (runId, detail) => `Failed to resume stalled workflow run ${runId}: ${detail}`),
70
98
  };
@@ -0,0 +1,16 @@
1
+ import type { StepState } from './workflow.types.js';
2
+ /** What claiming a step needs from the workflow service. */
3
+ export type StepClaimStore = {
4
+ getStepState(runId: string, stepName: string): Promise<StepState>;
5
+ setStepRunning(stepId: string): Promise<void>;
6
+ createRetryAttempt(failedStepId: string, status: 'pending' | 'running'): Promise<StepState>;
7
+ };
8
+ /**
9
+ * Decide whether this dispatch owns the step, by reading its state and then
10
+ * writing it — which only excludes a concurrent dispatch when the caller holds
11
+ * a lock that genuinely excludes one.
12
+ *
13
+ * A store that can express the whole decision as a single conditional write
14
+ * should do that instead of calling this.
15
+ */
16
+ export declare const claimStepByReadThenWrite: (store: StepClaimStore, runId: string, stepName: string, rpcName: string) => Promise<StepState | null>;
@@ -0,0 +1,27 @@
1
+ import { WorkflowStepFunctionMismatchError } from './workflow-errors.js';
2
+ /**
3
+ * Decide whether this dispatch owns the step, by reading its state and then
4
+ * writing it — which only excludes a concurrent dispatch when the caller holds
5
+ * a lock that genuinely excludes one.
6
+ *
7
+ * A store that can express the whole decision as a single conditional write
8
+ * should do that instead of calling this.
9
+ */
10
+ export const claimStepByReadThenWrite = async (store, runId, stepName, rpcName) => {
11
+ const stepState = await store.getStepState(runId, stepName);
12
+ // knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
13
+ if (stepState.rpcName !== undefined &&
14
+ stepState.rpcName !== (rpcName ?? null)) {
15
+ throw new WorkflowStepFunctionMismatchError(runId, stepName);
16
+ }
17
+ if (stepState.status === 'succeeded' || stepState.status === 'running') {
18
+ return null;
19
+ }
20
+ if (stepState.status === 'failed') {
21
+ return store.createRetryAttempt(stepState.stepId, 'running');
22
+ }
23
+ if (stepState.status === 'pending' || stepState.status === 'scheduled') {
24
+ await store.setStepRunning(stepState.stepId);
25
+ }
26
+ return stepState;
27
+ };
@@ -0,0 +1,47 @@
1
+ ---
2
+ type: decision
3
+ title: A scaffold flag says a surface exists, not who may call it
4
+ description: scaffold.<feature> is boolean | { path } — the auth field is gone, because the function runner already enforces each generated function's own auth, wiring, scopes and addon
5
+ tags: config, authorization, codegen, scaffold
6
+ ---
7
+
8
+ # A scaffold flag says a surface exists, not who may call it
9
+
10
+ `scaffold.<feature>` carried an `auth` field, defaulting closed — see
11
+ [[scaffold-features-are-authenticated-unless-opted-out]], which this supersedes.
12
+ The field is gone. `PikkuScaffoldFeature` is now `boolean | { path?: string }`:
13
+ whether the surface is generated, and where the file goes.
14
+
15
+ The field was a second gate in front of one that already runs. `resolveSession`
16
+ in `packages/core/src/function/function-runner.ts` throws `ForbiddenError` for a
17
+ `pikkuFunc` with no session, unconditionally; a `pikkuSessionlessFunc` requires
18
+ one when its own `auth: true` says so, when its wiring says so, or when
19
+ `resolveAddonAuth` does. That is where authentication is decided, per function,
20
+ by whoever wrote it. A config flag three directories away could only be coarser
21
+ than that, and being coarser was the whole problem the earlier decision was
22
+ trying to work around by defaulting it closed.
23
+
24
+ So the generated dispatchers — public RPC, public agent, workflow routes and the
25
+ events channel — emit a fixed `auth: false` on every function and wiring they
26
+ write. That is not the scaffold declaring the surface public. It is the
27
+ dispatcher declining to gate, because it does not know what it is forwarding to:
28
+ `rpcCaller` hands whatever name it was given to `rpc.exposed`, and the function
29
+ that resolves there is the one that answers for itself. Omitting the field would
30
+ not have been neutral — a wiring with no `auth` requires a session — so the
31
+ wrapper would reject the call before the gate that decides ever ran, which is a
32
+ harder gate than the config field it replaced, applied by the layer least
33
+ qualified to apply it.
34
+
35
+ The scoped admin surfaces do not emit it. `userAdmin` and `virtualUser` generate
36
+ `pikkuFunc` with `scopes: ['admin:users:...']` — session-required by
37
+ construction, and the function that decides rather than a wrapper in front of
38
+ one. `auth: false` there is refused by `runPikkuFunc` with a warning telling you
39
+ to use `pikkuSessionlessFunc` instead, and `auth: true` would only restate the
40
+ type.
41
+
42
+ `pikku enable <feature>` lost its `--noAuth` flag with the field it wrote.
43
+
44
+ **What this rules out:** reading a scaffold flag as an authorization decision;
45
+ gating a surface in the config instead of on the function that answers it; and
46
+ letting a generated dispatcher inherit the default `auth: true`, which reads as
47
+ "undecided" and behaves as "denied".
@@ -50,7 +50,8 @@ A rule about who may do what, and which way it fails when it is unsure.
50
50
  - [Queue job identities are signed at enqueue](queue-job-identities-are-signed-at-enqueue.md) — A job's pikkuUserId is an HMAC claim bound to the queue and payload; an unverifiable claim is dropped, never trusted
51
51
  - [Queue jobs carry the producer's pikku user id](queue-jobs-carry-the-producers-pikku-user-id.md) — A job's pikkuUserId is trusted as identity by the worker, so enqueue rights are effectively act-as-user rights
52
52
  - [Remote addon tokens are client credentials, not mesh trust](remote-addon-tokens-are-client-credentials-not-mesh-trust.md) — wireRemoteAddon authenticates as a client to a hosted library and fails closed on an empty token; it never uses PIKKU_REMOTE_SECRET
53
- - [A scaffolded surface is authenticated unless the config opts out in writing](scaffold-features-are-authenticated-unless-opted-out.md) — scaffold.<feature> became boolean | { auth, path }, where true means authenticated going public requires typing { auth: false }, and the legacy 'auth' | 'no-auth' strings are refused rather than coerced
53
+ - [A scaffold flag says a surface exists, not who may call it](a-scaffold-flag-says-a-surface-exists-not-who-may-call-it.md) — scaffold.<feature> is boolean | { path }; the auth field is gone because the function runner already enforces each generated function's own auth, wiring, scopes and addon
54
+ - [A scaffolded surface is authenticated unless the config opts out in writing](scaffold-features-are-authenticated-unless-opted-out.md) — SUPERSEDED; retains why the legacy 'auth' | 'no-auth' strings are refused rather than coerced
54
55
  - [Scenario-step functions are never externally invocable over RPC](scenario-step-functions-are-never-externally-invocable.md) — rpcExposed requires expose and rejects scenarioStep, so test steps stay reachable only from inside a scenario run
55
56
  - [Scope resolution happens at the session boundary and scope sync never deletes](scope-resolution-happens-at-the-session-boundary-and-sync-never-deletes.md) — ScopeService is called when a session is built, never by the function runner, and syncScopes only ever adds — revoking is an explicit operation
56
57
  - [Self-authentication is declared, not detected](self-authentication-is-declared-not-detected.md) — A function that authorizes callers in its own body says so with selfAuthenticated; codegen never tries to infer it
@@ -7,6 +7,12 @@ tags: config, authorization, codegen, scaffold
7
7
 
8
8
  # A scaffolded surface is authenticated unless the config opts out in writing
9
9
 
10
+ **Superseded by
11
+ [[a-scaffold-flag-says-a-surface-exists-not-who-may-call-it]]**: the `auth`
12
+ field is gone entirely, because the function runner already enforces each
13
+ generated function's own auth. The reasoning below still explains why the
14
+ legacy `'auth' | 'no-auth'` strings are refused rather than coerced.
15
+
10
16
  `scaffold.<feature>` was `'auth' | 'no-auth' | false`. Two things were wrong
11
17
  with it, and they compounded.
12
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.86",
3
+ "version": "0.12.89",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -11,7 +11,7 @@ import type { Logger } from '../services/logger.js'
11
11
  import type { CorePikkuFunctionConfig } from '../function/functions.types.js'
12
12
  import { createModuleRunner } from './module-runner.js'
13
13
 
14
- export * from './reload-meta.js'
14
+ export { reloadGeneratedMeta, reconcileAddonRegistry } from './reload-meta.js'
15
15
 
16
16
  interface PikkuDevReloaderOptions {
17
17
  srcDirectories: string[]
@@ -240,7 +240,7 @@ export type PikkuWire<
240
240
  * sets it; services that log fall back to the singleton logger.
241
241
  */
242
242
  logger: Logger
243
- /** Trace ID for distributed tracing — propagated across remote RPC calls via x-trace-id header */
243
+ /** Trace ID for distributed tracing — propagated across remote RPC calls via the x-request-id header */
244
244
  traceId: string
245
245
  functionId: string
246
246
  addonNamespace: string
@@ -1,2 +1,25 @@
1
- export * from './core.types.js'
1
+ export type {
2
+ AuthInstance,
3
+ CommonWireMeta,
4
+ CoreConfig,
5
+ CoreServices,
6
+ CoreSingletonServices,
7
+ CoreUserSession,
8
+ CreateConfig,
9
+ CreateSingletonServices,
10
+ CreateWireServices,
11
+ GetCredential,
12
+ PikkuWire,
13
+ PikkuWiringTypes,
14
+ PostgresConfig,
15
+ SecretlessServices,
16
+ SecurityAuditIssue,
17
+ SecurityAuditReport,
18
+ SecurityAuditSummary,
19
+ SecurityAuditUpdate,
20
+ SecuritySeverity,
21
+ SecurityUpdateLevel,
22
+ ServerLifecycle,
23
+ WireServices,
24
+ } from './core.types.js'
2
25
  export type * from './state.types.js'
@@ -74,6 +74,72 @@ describe('agent-memory', () => {
74
74
  })
75
75
  })
76
76
 
77
+ test('deepMergeWorkingMemory replaces an array re-emitted in full', () => {
78
+ const result = deepMergeWorkingMemory(
79
+ { steps: [{ id: 1 }, { id: 2 }], other: 'kept' },
80
+ { steps: [{ id: 1 }, { id: 2 }, { id: 3 }] }
81
+ )
82
+
83
+ assert.deepEqual(result, {
84
+ steps: [{ id: 1 }, { id: 2 }, { id: 3 }],
85
+ other: 'kept',
86
+ })
87
+ })
88
+
89
+ test('deepMergeWorkingMemory replaces rather than appends a partial array', () => {
90
+ const result = deepMergeWorkingMemory(
91
+ { steps: [{ id: 1 }, { id: 2 }] },
92
+ { steps: [{ id: 3 }] }
93
+ )
94
+
95
+ assert.deepEqual(result, { steps: [{ id: 3 }] })
96
+ })
97
+
98
+ test('deepMergeWorkingMemory clears an array field set to null', () => {
99
+ const result = deepMergeWorkingMemory(
100
+ { steps: [1, 2, 3], city: 'Berlin' },
101
+ {
102
+ steps: null,
103
+ }
104
+ )
105
+
106
+ assert.deepEqual(result, { city: 'Berlin' })
107
+ })
108
+
109
+ test('deepMergeWorkingMemory replaces arrays nested inside merged objects', () => {
110
+ const result = deepMergeWorkingMemory(
111
+ {
112
+ profile: {
113
+ name: 'Yasser',
114
+ tags: ['a', 'b'],
115
+ },
116
+ },
117
+ {
118
+ profile: {
119
+ tags: ['c'],
120
+ },
121
+ }
122
+ )
123
+
124
+ assert.deepEqual(result, {
125
+ profile: {
126
+ name: 'Yasser',
127
+ tags: ['c'],
128
+ },
129
+ })
130
+ })
131
+
132
+ test('deepMergeWorkingMemory replaces across array and object shape changes', () => {
133
+ assert.deepEqual(
134
+ deepMergeWorkingMemory({ value: { a: 1 } }, { value: [1, 2] }),
135
+ { value: [1, 2] }
136
+ )
137
+ assert.deepEqual(
138
+ deepMergeWorkingMemory({ value: [1, 2] }, { value: { a: 1 } }),
139
+ { value: { a: 1 } }
140
+ )
141
+ })
142
+
77
143
  test('deepMergeWorkingMemory ignores prototype-polluting keys', () => {
78
144
  const before = ({} as any).isAdmin
79
145
  deepMergeWorkingMemory(
@@ -107,6 +173,13 @@ describe('agent-memory', () => {
107
173
  assert.match(prompt, /<working_memory>/)
108
174
  })
109
175
 
176
+ test('buildWorkingMemoryPrompt tells the model arrays are replaced whole', () => {
177
+ const prompt = buildWorkingMemoryPrompt({ steps: [1] })
178
+
179
+ assert.match(prompt, /Arrays are replaced, not merged/)
180
+ assert.match(prompt, /repeat every item you want to keep/)
181
+ })
182
+
110
183
  test('buildWorkingMemoryPrompt marks empty memory explicitly', () => {
111
184
  const prompt = buildWorkingMemoryPrompt(null)
112
185
  assert.match(prompt, /Current working memory: \(empty\)/)
@@ -39,6 +39,17 @@ export function isWorkingMemoryEnabled(
39
39
  return !!memoryConfig?.workingMemory && !!storage
40
40
  }
41
41
 
42
+ /**
43
+ * Merges a model-emitted working memory update into the stored state.
44
+ *
45
+ * Objects merge key by key, and `null` deletes a key. Arrays are replaced
46
+ * wholesale — deliberately, not as a side effect of the object guard below.
47
+ * The full state is echoed back to the model every turn by
48
+ * {@link buildWorkingMemoryPrompt}, so it can always re-emit an array in full,
49
+ * and appending instead would duplicate every item each time it did.
50
+ * `buildWorkingMemoryPrompt` states this contract to the model; the two must
51
+ * stay in agreement.
52
+ */
42
53
  export function deepMergeWorkingMemory(
43
54
  existing: Record<string, unknown>,
44
55
  updates: Record<string, unknown>
@@ -51,9 +62,10 @@ export function deepMergeWorkingMemory(
51
62
  const value = updates[key]
52
63
  if (value === null) {
53
64
  delete result[key]
65
+ } else if (Array.isArray(value)) {
66
+ result[key] = value
54
67
  } else if (
55
68
  typeof value === 'object' &&
56
- !Array.isArray(value) &&
57
69
  typeof result[key] === 'object' &&
58
70
  result[key] !== null &&
59
71
  !Array.isArray(result[key])
@@ -100,7 +112,9 @@ export function buildWorkingMemoryPrompt(
100
112
  'When you learn new information, output a partial JSON update in <working_memory> tags. ' +
101
113
  'Only include durable facts you have actually derived or the user has confirmed. ' +
102
114
  'Do not output templates, placeholders, or narration. ' +
103
- 'Only include changed fields. Leave unknown fields untouched. Set a field to null to delete it.'
115
+ 'Only include changed fields. Leave unknown fields untouched. Set a field to null to delete it. ' +
116
+ 'Arrays are replaced, not merged: to change one, repeat every item you want to keep alongside the new ones, ' +
117
+ 'because any item you leave out is deleted.'
104
118
  )
105
119
 
106
120
  return parts.join('\n\n')
@@ -0,0 +1,41 @@
1
+ // Type-checked via tsconfig.type-tests.json; no runtime assertions.
2
+
3
+ import { pikkuAgentMiddleware } from '../../middleware/middleware-factories.js'
4
+ import type { PikkuAgentMiddlewareHooks } from './agent.types.js'
5
+
6
+ const _singletonServicesCompile = pikkuAgentMiddleware<{ count: number }>({
7
+ modifyInput: async ({ logger }, { messages, instructions }) => {
8
+ logger.info(`agent input: ${messages.length} messages`)
9
+ return { messages, instructions }
10
+ },
11
+ modifyOutputStream: async ({ logger }, { event, state }) => {
12
+ state.count = (state.count ?? 0) + 1
13
+ logger.info(`event: ${event.type}`)
14
+ return event
15
+ },
16
+ afterStep: async ({ variables }, { stepNumber }) => {
17
+ variables.get(`STEP_${stepNumber}`)
18
+ },
19
+ onError: async ({ logger }, { error }) => {
20
+ logger.error(error.message)
21
+ },
22
+ })
23
+ void _singletonServicesCompile
24
+
25
+ const _wireServiceIsNotAvailable: PikkuAgentMiddlewareHooks = {
26
+ // @ts-expect-error — `http` is a wire service, and an agent run has no wire
27
+ modifyInput: async ({ http }, { messages, instructions }) => {
28
+ void http
29
+ return { messages, instructions }
30
+ },
31
+ }
32
+ void _wireServiceIsNotAvailable
33
+
34
+ const _wireServiceIsNotAvailableToTheFactory = pikkuAgentMiddleware({
35
+ // @ts-expect-error — `channel` is a wire service, and an agent run has no wire
36
+ afterToolCall: async ({ channel }, { result }) => {
37
+ void channel
38
+ return { result }
39
+ },
40
+ })
41
+ void _wireServiceIsNotAvailableToTheFactory