@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.
- package/CHANGELOG.md +115 -0
- package/dist/dev/hot-reload.d.ts +1 -1
- package/dist/dev/hot-reload.js +1 -1
- package/dist/types/core.types.d.ts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.js +1 -1
- package/dist/wirings/agent/agent-memory.d.ts +11 -0
- package/dist/wirings/agent/agent-memory.js +17 -2
- package/dist/wirings/agent/agent-stream.js +115 -91
- package/dist/wirings/agent/agent.types.d.ts +9 -8
- package/dist/wirings/rpc/rpc-runner.js +1 -1
- package/dist/wirings/variable/validate-variable-definitions.js +2 -2
- package/dist/wirings/variable/variable.types.d.ts +13 -7
- package/dist/wirings/workflow/pikku-scenario-service.d.ts +2 -2
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +20 -6
- package/dist/wirings/workflow/pikku-workflow-service.js +29 -27
- package/dist/wirings/workflow/workflow-constants.d.ts +17 -0
- package/dist/wirings/workflow/workflow-constants.js +17 -0
- package/dist/wirings/workflow/workflow-recovery.d.ts +18 -1
- package/dist/wirings/workflow/workflow-recovery.js +30 -2
- package/dist/wirings/workflow/workflow-step-claim.d.ts +16 -0
- package/dist/wirings/workflow/workflow-step-claim.js +27 -0
- package/knowledge/decisions/security/a-scaffold-flag-says-a-surface-exists-not-who-may-call-it.md +47 -0
- package/knowledge/decisions/security/index.md +2 -1
- package/knowledge/decisions/security/scaffold-features-are-authenticated-unless-opted-out.md +6 -0
- package/package.json +1 -1
- package/src/dev/hot-reload.ts +1 -1
- package/src/types/core.types.ts +1 -1
- package/src/types/index.ts +24 -1
- package/src/wirings/agent/agent-memory.test.ts +73 -0
- package/src/wirings/agent/agent-memory.ts +16 -2
- package/src/wirings/agent/agent-middleware.types.test.ts +41 -0
- package/src/wirings/agent/agent-stream-delegate.test.ts +381 -0
- package/src/wirings/agent/agent-stream.ts +158 -78
- package/src/wirings/agent/agent.types.ts +9 -8
- package/src/wirings/rpc/rpc-runner.test.ts +6 -1
- package/src/wirings/rpc/rpc-runner.ts +1 -1
- package/src/wirings/variable/validate-variable-definitions.test.ts +11 -10
- package/src/wirings/variable/validate-variable-definitions.ts +2 -2
- package/src/wirings/variable/variable.types.ts +13 -7
- package/src/wirings/workflow/pikku-scenario-service.ts +29 -6
- package/src/wirings/workflow/pikku-workflow-service.ts +34 -27
- package/src/wirings/workflow/workflow-constants.ts +19 -0
- package/src/wirings/workflow/workflow-recovery.ts +31 -1
- package/src/wirings/workflow/workflow-stalled-recovery.test.ts +46 -0
- package/src/wirings/workflow/workflow-step-claim.ts +46 -0
- package/src/wirings/workflow/workflow-terminal-run-guard.test.ts +105 -0
- package/tsconfig.tsbuildinfo +1 -1
- 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 `
|
|
109
|
-
* or no concurrency for one to exclude: the relay makes
|
|
110
|
-
* routine, and the claim
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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,
|
|
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 `
|
|
265
|
-
* or no concurrency for one to exclude: the relay makes
|
|
266
|
-
* routine, and the claim
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
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.
|
|
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
|
|
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
|
+
};
|
package/knowledge/decisions/security/a-scaffold-flag-says-a-surface-exists-not-who-may-call-it.md
ADDED
|
@@ -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
|
|
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
|
package/knowledge/decisions/security/scaffold-features-are-authenticated-unless-opted-out.md
CHANGED
|
@@ -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
package/src/dev/hot-reload.ts
CHANGED
|
@@ -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
|
|
14
|
+
export { reloadGeneratedMeta, reconcileAddonRegistry } from './reload-meta.js'
|
|
15
15
|
|
|
16
16
|
interface PikkuDevReloaderOptions {
|
|
17
17
|
srcDirectories: string[]
|
package/src/types/core.types.ts
CHANGED
|
@@ -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-
|
|
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
|
package/src/types/index.ts
CHANGED
|
@@ -1,2 +1,25 @@
|
|
|
1
|
-
export
|
|
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
|