@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
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
CorePikkuMiddleware,
|
|
7
7
|
MiddlewareMetadata,
|
|
8
8
|
} from '../../middleware/middleware.types.js'
|
|
9
|
+
import type { CoreSingletonServices } from '../../types/core.types.js'
|
|
9
10
|
import type { PermissionMetadata } from '../../function/function-meta.types.js'
|
|
10
11
|
import type { AIProviderOptions } from '../../services/agent-runner-service.js'
|
|
11
12
|
import type { PikkuChannel } from '../channel/channel.types.js'
|
|
@@ -180,10 +181,10 @@ export interface AgentToolDef extends Partial<ApprovalPolicy> {
|
|
|
180
181
|
|
|
181
182
|
export interface PikkuAgentMiddlewareHooks<
|
|
182
183
|
State extends Record<string, unknown> = Record<string, unknown>,
|
|
183
|
-
|
|
184
|
+
SingletonServices extends CoreSingletonServices = CoreSingletonServices,
|
|
184
185
|
> {
|
|
185
186
|
modifyInput?: (
|
|
186
|
-
services:
|
|
187
|
+
services: SingletonServices,
|
|
187
188
|
ctx: {
|
|
188
189
|
messages: AgentMessage[]
|
|
189
190
|
instructions: string
|
|
@@ -210,7 +211,7 @@ export interface PikkuAgentMiddlewareHooks<
|
|
|
210
211
|
| { messages: AgentMessage[]; instructions: string }
|
|
211
212
|
|
|
212
213
|
modifyOutputStream?: (
|
|
213
|
-
services:
|
|
214
|
+
services: SingletonServices,
|
|
214
215
|
ctx: {
|
|
215
216
|
event: AgentStreamEvent
|
|
216
217
|
allEvents: readonly AgentStreamEvent[]
|
|
@@ -262,7 +263,7 @@ export interface PikkuAgentMiddlewareHooks<
|
|
|
262
263
|
* leaves them untouched.
|
|
263
264
|
*/
|
|
264
265
|
modifyOutput?: (
|
|
265
|
-
services:
|
|
266
|
+
services: SingletonServices,
|
|
266
267
|
ctx: {
|
|
267
268
|
text: string
|
|
268
269
|
messages: AgentMessage[]
|
|
@@ -282,7 +283,7 @@ export interface PikkuAgentMiddlewareHooks<
|
|
|
282
283
|
}
|
|
283
284
|
|
|
284
285
|
beforeToolCall?: (
|
|
285
|
-
services:
|
|
286
|
+
services: SingletonServices,
|
|
286
287
|
ctx: {
|
|
287
288
|
toolName: string
|
|
288
289
|
toolCallId: string
|
|
@@ -294,7 +295,7 @@ export interface PikkuAgentMiddlewareHooks<
|
|
|
294
295
|
| void
|
|
295
296
|
|
|
296
297
|
afterToolCall?: (
|
|
297
|
-
services:
|
|
298
|
+
services: SingletonServices,
|
|
298
299
|
ctx: {
|
|
299
300
|
toolName: string
|
|
300
301
|
toolCallId: string
|
|
@@ -305,7 +306,7 @@ export interface PikkuAgentMiddlewareHooks<
|
|
|
305
306
|
) => Promise<{ result: unknown } | void> | { result: unknown } | void
|
|
306
307
|
|
|
307
308
|
afterStep?: (
|
|
308
|
-
services:
|
|
309
|
+
services: SingletonServices,
|
|
309
310
|
ctx: {
|
|
310
311
|
stepNumber: number
|
|
311
312
|
text: string
|
|
@@ -323,7 +324,7 @@ export interface PikkuAgentMiddlewareHooks<
|
|
|
323
324
|
) => Promise<void> | void
|
|
324
325
|
|
|
325
326
|
onError?: (
|
|
326
|
-
services:
|
|
327
|
+
services: SingletonServices,
|
|
327
328
|
ctx: {
|
|
328
329
|
error: Error
|
|
329
330
|
stepNumber: number
|
|
@@ -900,7 +900,12 @@ describe('wireRemoteAddon dispatch', () => {
|
|
|
900
900
|
calls[0]!.init.headers.authorization,
|
|
901
901
|
'Bearer secret-value-for-REGISTRY_TOKEN'
|
|
902
902
|
)
|
|
903
|
-
assert.equal(
|
|
903
|
+
assert.equal(
|
|
904
|
+
calls[0]!.init.headers['x-request-id'],
|
|
905
|
+
'trace-r',
|
|
906
|
+
'a remote RPC must send the trace id under the header the receiving ' +
|
|
907
|
+
'runner reads, or the trace chain breaks at the hop'
|
|
908
|
+
)
|
|
904
909
|
} finally {
|
|
905
910
|
restoreFetch()
|
|
906
911
|
}
|
|
@@ -356,7 +356,7 @@ export class ContextAwareRPCService {
|
|
|
356
356
|
headers.authorization = `Bearer ${token}`
|
|
357
357
|
}
|
|
358
358
|
if (this.wire.traceId) {
|
|
359
|
-
headers['x-
|
|
359
|
+
headers['x-request-id'] = this.wire.traceId
|
|
360
360
|
}
|
|
361
361
|
|
|
362
362
|
const base = serverUrl.replace(/\/+$/, '')
|
|
@@ -89,26 +89,27 @@ describe('validateAndBuildVariableDefinitionsMeta', () => {
|
|
|
89
89
|
assert.deepStrictEqual(result, {})
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
-
test('carries
|
|
92
|
+
test('carries optional into the meta so only the opted-out ones skip the gate', () => {
|
|
93
93
|
const result = validateAndBuildVariableDefinitionsMeta(
|
|
94
94
|
[
|
|
95
|
-
{
|
|
96
|
-
name: 'consoleUrl',
|
|
97
|
-
displayName: 'Console URL',
|
|
98
|
-
variableId: 'CONSOLE_URL',
|
|
99
|
-
required: true,
|
|
100
|
-
sourceFile: 'a.ts',
|
|
101
|
-
},
|
|
102
95
|
{
|
|
103
96
|
name: 'corsOrigins',
|
|
104
97
|
displayName: 'Allowed Browser Origins',
|
|
105
98
|
variableId: 'CORS_ORIGINS',
|
|
99
|
+
optional: true,
|
|
100
|
+
sourceFile: 'a.ts',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'consoleUrl',
|
|
104
|
+
displayName: 'Console URL',
|
|
105
|
+
variableId: 'CONSOLE_URL',
|
|
106
106
|
sourceFile: 'a.ts',
|
|
107
107
|
},
|
|
108
108
|
] as any,
|
|
109
109
|
new Map()
|
|
110
110
|
)
|
|
111
|
-
assert.strictEqual(result['
|
|
112
|
-
|
|
111
|
+
assert.strictEqual(result['corsOrigins']!.optional, true)
|
|
112
|
+
// Undeclared means required, which is what makes the gate ask by default.
|
|
113
|
+
assert.strictEqual(result['consoleUrl']!.optional, undefined)
|
|
113
114
|
})
|
|
114
115
|
})
|
|
@@ -43,7 +43,7 @@ export function validateAndBuildVariableDefinitionsMeta(
|
|
|
43
43
|
description: def.description,
|
|
44
44
|
variableId: def.variableId,
|
|
45
45
|
schema: def.schema,
|
|
46
|
-
|
|
46
|
+
optional: def.optional,
|
|
47
47
|
docsUrl: def.docsUrl,
|
|
48
48
|
sourceFile: def.sourceFile,
|
|
49
49
|
}
|
|
@@ -60,7 +60,7 @@ export function validateAndBuildVariableDefinitionsMeta(
|
|
|
60
60
|
description: def.description,
|
|
61
61
|
variableId: def.variableId,
|
|
62
62
|
schema: def.schema,
|
|
63
|
-
|
|
63
|
+
optional: def.optional,
|
|
64
64
|
docsUrl: def.docsUrl,
|
|
65
65
|
sourceFile: def.sourceFile,
|
|
66
66
|
}
|
|
@@ -5,13 +5,19 @@ export type CoreVariable<T = unknown> = {
|
|
|
5
5
|
variableId: string
|
|
6
6
|
schema: T
|
|
7
7
|
/**
|
|
8
|
-
* A variable is
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* A variable is REQUIRED by default, and marking it `optional` is how a
|
|
9
|
+
* declaration says its absence is a supported state. Same flag, same
|
|
10
|
+
* polarity and same meaning as `CoreSecret.optional` — one word to learn
|
|
11
|
+
* rather than two with opposite senses.
|
|
12
|
+
*
|
|
13
|
+
* Defaulting to required rather than following `variables.get`'s
|
|
14
|
+
* `T | undefined` return is deliberate. That signature describes what a
|
|
15
|
+
* caller must HANDLE, not whether a deployment is correct without the value:
|
|
16
|
+
* an undefined feature flag is fine, an undefined API base URL is an outage
|
|
17
|
+
* that the type system cannot tell apart. Declaring the difference is the
|
|
18
|
+
* point of the flag, and the safe default for an undeclared one is to ask.
|
|
13
19
|
*/
|
|
14
|
-
|
|
20
|
+
optional?: boolean
|
|
15
21
|
docsUrl?: string
|
|
16
22
|
}
|
|
17
23
|
|
|
@@ -21,7 +27,7 @@ export type VariableDefinitionMeta = {
|
|
|
21
27
|
description?: string
|
|
22
28
|
variableId: string
|
|
23
29
|
schema?: Record<string, unknown> | string
|
|
24
|
-
|
|
30
|
+
optional?: boolean
|
|
25
31
|
docsUrl?: string
|
|
26
32
|
sourceFile?: string
|
|
27
33
|
}
|
|
@@ -59,8 +59,34 @@ const assertionStep = <T extends WorkflowStepOptions>(
|
|
|
59
59
|
({ ...options, retries: options?.retries ?? 0 }) as T & { retries: number }
|
|
60
60
|
|
|
61
61
|
export { addFeature, resolveFeatureScenarios } from './feature.js'
|
|
62
|
-
export type
|
|
63
|
-
|
|
62
|
+
export type {
|
|
63
|
+
CoreFeature,
|
|
64
|
+
CoreFeatureScenario,
|
|
65
|
+
FeatureMeta,
|
|
66
|
+
FeaturesMeta,
|
|
67
|
+
PikkuBrowserWire,
|
|
68
|
+
PikkuScenarioWire,
|
|
69
|
+
ScenarioBrowserFailure,
|
|
70
|
+
ScenarioBrowserProvider,
|
|
71
|
+
ScenarioEnvironment,
|
|
72
|
+
ScenarioStepKind,
|
|
73
|
+
ScenarioStepMeta,
|
|
74
|
+
ScenarioStepOptions,
|
|
75
|
+
ScenarioStepPhase,
|
|
76
|
+
ScenarioSurface,
|
|
77
|
+
TestIdSelector,
|
|
78
|
+
} from './scenario.types.js'
|
|
79
|
+
export type {
|
|
80
|
+
ScenarioArtifact,
|
|
81
|
+
ScenarioFailureDetail,
|
|
82
|
+
ScenarioResult,
|
|
83
|
+
ScenarioRunRecord,
|
|
84
|
+
ScenarioRunReport,
|
|
85
|
+
ScenarioRunStatus,
|
|
86
|
+
ScenarioRunStore,
|
|
87
|
+
ScenarioRunSummary,
|
|
88
|
+
ScenarioStepRow,
|
|
89
|
+
} from './scenario-run.types.js'
|
|
64
90
|
export { SCENARIO_SURFACES } from './scenario-step.types.js'
|
|
65
91
|
|
|
66
92
|
// Which of a step's bindings run: one for an action, every witness for a `then`
|
|
@@ -979,10 +1005,7 @@ export class PikkuScenarioService implements WorkflowRunExtension {
|
|
|
979
1005
|
* Whether a step is driven by a persona, and so must be given one. Stamped by
|
|
980
1006
|
* the definer from a `browser` binding or an explicit `actor: true`.
|
|
981
1007
|
*/
|
|
982
|
-
private requiresActor(
|
|
983
|
-
packageName: string | null,
|
|
984
|
-
stepFunc: string
|
|
985
|
-
): boolean {
|
|
1008
|
+
private requiresActor(packageName: string | null, stepFunc: string): boolean {
|
|
986
1009
|
return (
|
|
987
1010
|
this.scenarioStepConfig(packageName, stepFunc)?.requiresActor === true
|
|
988
1011
|
)
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
WORKFLOW_POLL_FACTOR,
|
|
60
60
|
WORKFLOW_POLL_MIN_MS,
|
|
61
61
|
WORKFLOW_TERMINAL_STATES,
|
|
62
|
+
isRunSettled,
|
|
62
63
|
} from './workflow-constants.js'
|
|
63
64
|
import {
|
|
64
65
|
WorkflowAsyncException,
|
|
@@ -68,7 +69,6 @@ import {
|
|
|
68
69
|
WorkflowRunCancelledError,
|
|
69
70
|
WorkflowRunFailedError,
|
|
70
71
|
WorkflowRunNotFoundError,
|
|
71
|
-
WorkflowStepFunctionMismatchError,
|
|
72
72
|
WorkflowStepNameNotString,
|
|
73
73
|
WorkflowSuspendedException,
|
|
74
74
|
} from './workflow-errors.js'
|
|
@@ -95,6 +95,7 @@ import {
|
|
|
95
95
|
} from './workflow-approval.js'
|
|
96
96
|
import { auditApprovalDecision } from './workflow-approval-audit.js'
|
|
97
97
|
import { recordSuspension, suspendStepNameFor } from './workflow-suspend.js'
|
|
98
|
+
import { claimStepByReadThenWrite } from './workflow-step-claim.js'
|
|
98
99
|
import {
|
|
99
100
|
RedispatchBackoff,
|
|
100
101
|
sweepStalledRuns,
|
|
@@ -639,12 +640,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
639
640
|
*
|
|
640
641
|
* Returns nothing by default so a store that cannot express the query keeps
|
|
641
642
|
* working unchanged — and, because it does not opt in, gains no re-dispatches
|
|
642
|
-
* either. A store must have an atomic `
|
|
643
|
-
* or no concurrency for one to exclude: the relay makes
|
|
644
|
-
* routine, and the claim
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
*
|
|
643
|
+
* either. A store must have an atomic `claimStepForExecution` before
|
|
644
|
+
* overriding this, or no concurrency for one to exclude: the relay makes
|
|
645
|
+
* duplicate dispatch routine, and the claim is what keeps a duplicate from
|
|
646
|
+
* becoming a second execution. Every `@pikku/kysely` dialect qualifies on its
|
|
647
|
+
* status-guarded claim, `in-memory` on being inline and single-process;
|
|
648
|
+
* `mongodb` still qualifies on neither.
|
|
648
649
|
*/
|
|
649
650
|
protected async findUndispatchedSteps(
|
|
650
651
|
_before: Date,
|
|
@@ -672,6 +673,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
672
673
|
}): Promise<{ resumed: string[] }> {
|
|
673
674
|
return sweepStalledRuns(
|
|
674
675
|
(before, limit) => this.findStalledRunIds(before, limit),
|
|
676
|
+
this.redispatchBackoff,
|
|
675
677
|
options,
|
|
676
678
|
this.sweepDeps
|
|
677
679
|
)
|
|
@@ -1084,6 +1086,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1084
1086
|
if (!run) {
|
|
1085
1087
|
throw new WorkflowRunNotFoundError(runId)
|
|
1086
1088
|
}
|
|
1089
|
+
if (isRunSettled(run.status)) return
|
|
1087
1090
|
|
|
1088
1091
|
const resolved = resolveWorkflowMeta(run.workflow)
|
|
1089
1092
|
const workflowMeta = resolved?.meta
|
|
@@ -1300,6 +1303,29 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1300
1303
|
}
|
|
1301
1304
|
}
|
|
1302
1305
|
|
|
1306
|
+
/**
|
|
1307
|
+
* Take sole ownership of a step before it runs, returning the state to run
|
|
1308
|
+
* under — or `null` when another dispatch already owns it.
|
|
1309
|
+
*
|
|
1310
|
+
* Dispatch is at-least-once by design: the relay re-dispatches steps it
|
|
1311
|
+
* believes were dropped, and a queue can redeliver a job it already handed
|
|
1312
|
+
* out. This is the one place that keeps a duplicate dispatch from becoming a
|
|
1313
|
+
* second execution of a side-effecting step, so it is only as strong as the
|
|
1314
|
+
* exclusion it is built on — and `withStepLock` excludes nothing unless the
|
|
1315
|
+
* store backs it with a real primitive. A store able to express the decision
|
|
1316
|
+
* as one conditional write should override this rather than reach for a lock,
|
|
1317
|
+
* which is what `@pikku/kysely` does with a status-guarded `UPDATE`.
|
|
1318
|
+
*/
|
|
1319
|
+
protected async claimStepForExecution(
|
|
1320
|
+
runId: string,
|
|
1321
|
+
stepName: string,
|
|
1322
|
+
rpcName: string
|
|
1323
|
+
): Promise<StepState | null> {
|
|
1324
|
+
return this.withStepLock(runId, stepName, () =>
|
|
1325
|
+
claimStepByReadThenWrite(this, runId, stepName, rpcName)
|
|
1326
|
+
)
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1303
1329
|
private async executeWorkflowStepInner(
|
|
1304
1330
|
runId: string,
|
|
1305
1331
|
stepName: string,
|
|
@@ -1307,26 +1333,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1307
1333
|
data: any,
|
|
1308
1334
|
rpcService: PikkuRPC
|
|
1309
1335
|
): Promise<void> {
|
|
1310
|
-
const claimed = await this.
|
|
1311
|
-
const stepState = await this.getStepState(runId, stepName)
|
|
1312
|
-
// knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
|
|
1313
|
-
if (
|
|
1314
|
-
stepState.rpcName !== undefined &&
|
|
1315
|
-
stepState.rpcName !== (rpcName ?? null)
|
|
1316
|
-
) {
|
|
1317
|
-
throw new WorkflowStepFunctionMismatchError(runId, stepName)
|
|
1318
|
-
}
|
|
1319
|
-
if (stepState.status === 'succeeded' || stepState.status === 'running') {
|
|
1320
|
-
return null
|
|
1321
|
-
}
|
|
1322
|
-
if (stepState.status === 'failed') {
|
|
1323
|
-
return this.createRetryAttempt(stepState.stepId, 'running')
|
|
1324
|
-
}
|
|
1325
|
-
if (stepState.status === 'pending' || stepState.status === 'scheduled') {
|
|
1326
|
-
await this.setStepRunning(stepState.stepId)
|
|
1327
|
-
}
|
|
1328
|
-
return stepState
|
|
1329
|
-
})
|
|
1336
|
+
const claimed = await this.claimStepForExecution(runId, stepName, rpcName)
|
|
1330
1337
|
|
|
1331
1338
|
if (!claimed) {
|
|
1332
1339
|
return
|
|
@@ -15,6 +15,25 @@ export const WORKFLOW_TERMINAL_STATES: ReadonlySet<string> = new Set([
|
|
|
15
15
|
'cancelled',
|
|
16
16
|
])
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* True for a run that will never move again, whatever arrives for it.
|
|
20
|
+
*
|
|
21
|
+
* Worth checking before doing anything with an orchestrator message, because
|
|
22
|
+
* such a message is routine rather than exceptional: the queue is
|
|
23
|
+
* at-least-once, the relay re-dispatches on purpose, and a run can settle
|
|
24
|
+
* while a message for it is still in flight. Replaying one is not free —
|
|
25
|
+
* `runWorkflowJob` takes the run lock and re-enters the workflow body, and a
|
|
26
|
+
* body re-entered after its run failed can park on a wait that nothing will
|
|
27
|
+
* ever satisfy, holding the lock and the connection under it until something
|
|
28
|
+
* external gives up. Every leaked advisory lock seen in production traced back
|
|
29
|
+
* to that: a granted lock, an idle session, and a run already `failed`.
|
|
30
|
+
*
|
|
31
|
+
* Note `suspended` is deliberately absent. It ends a run's *current* pass but
|
|
32
|
+
* not the run, which resumes when its approval or signal arrives.
|
|
33
|
+
*/
|
|
34
|
+
export const isRunSettled = (status: string): boolean =>
|
|
35
|
+
WORKFLOW_TERMINAL_STATES.has(status)
|
|
36
|
+
|
|
18
37
|
export const WORKFLOW_POLL_MIN_MS = 10
|
|
19
38
|
|
|
20
39
|
export const WORKFLOW_POLL_FACTOR = 1.6
|
|
@@ -17,6 +17,15 @@ import {
|
|
|
17
17
|
* owed a job, so holding off a single step while resuming its run would
|
|
18
18
|
* suppress nothing.
|
|
19
19
|
*
|
|
20
|
+
* For the same reason one instance is shared by every sweep rather than kept
|
|
21
|
+
* per sweep. The record is of the action, not of the signal that prompted it:
|
|
22
|
+
* a stalled run and an undispatched step are different observations, but both
|
|
23
|
+
* are answered by the one orchestrator message, so a run the relay re-drove a
|
|
24
|
+
* moment ago gains nothing from the stalled sweep re-driving it again. Sharing
|
|
25
|
+
* is what makes the guarantee a message-per-run-per-window instead of one per
|
|
26
|
+
* sweep, and no recovery is lost by it: whichever sweep gets there first
|
|
27
|
+
* performs the identical re-drive, and the cap keeps the delay at 10m.
|
|
28
|
+
*
|
|
20
29
|
* Losing this on restart costs extra dispatches, never correctness.
|
|
21
30
|
*/
|
|
22
31
|
export class RedispatchBackoff {
|
|
@@ -88,19 +97,40 @@ const resumeEach = async (
|
|
|
88
97
|
* actually stuck costs an orchestration pass and changes nothing. That
|
|
89
98
|
* idempotence is what makes an idle-time heuristic safe here; a run that is
|
|
90
99
|
* legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
|
|
100
|
+
*
|
|
101
|
+
* Idempotent is not free, though: a run stays stalled until something clears
|
|
102
|
+
* the reason it stalled, so an unconditional sweep re-queues the same runs on
|
|
103
|
+
* every tick forever. That is how a handful of wedged runs became a queue of
|
|
104
|
+
* thousands that could not drain — each pass added work the previous pass had
|
|
105
|
+
* not finished. The same per-run backoff the relay uses bounds it: a run is
|
|
106
|
+
* re-driven, then held off for a doubling delay, so a sweep costs at most one
|
|
107
|
+
* message per run per window rather than one per run per tick.
|
|
91
108
|
*/
|
|
92
109
|
export const sweepStalledRuns = async (
|
|
93
110
|
findStalledRunIds: (before: Date, limit: number) => Promise<string[]>,
|
|
111
|
+
backoff: RedispatchBackoff,
|
|
94
112
|
options: { stalledAfterMs?: number; limit?: number } | undefined,
|
|
95
113
|
deps: SweepDeps
|
|
96
114
|
): Promise<{ resumed: string[] }> => {
|
|
97
115
|
const before = new Date(
|
|
98
116
|
Date.now() - (options?.stalledAfterMs ?? DEFAULT_STALLED_RUN_MS)
|
|
99
117
|
)
|
|
100
|
-
const
|
|
118
|
+
const found = await findStalledRunIds(
|
|
101
119
|
before,
|
|
102
120
|
options?.limit ?? DEFAULT_STALLED_RUN_LIMIT
|
|
103
121
|
)
|
|
122
|
+
|
|
123
|
+
const now = Date.now()
|
|
124
|
+
const runIds: string[] = []
|
|
125
|
+
for (const runId of found) {
|
|
126
|
+
if (!backoff.isEligible(runId, now)) continue
|
|
127
|
+
// Noted before the resume, not after: a run whose resume throws is exactly
|
|
128
|
+
// the run most likely to still be here next tick, and re-driving it every
|
|
129
|
+
// tick is the amplification this backoff exists to stop.
|
|
130
|
+
backoff.note(runId, now)
|
|
131
|
+
runIds.push(runId)
|
|
132
|
+
}
|
|
133
|
+
|
|
104
134
|
return {
|
|
105
135
|
resumed: await resumeEach(
|
|
106
136
|
runIds,
|
|
@@ -89,6 +89,52 @@ describe('stalled run recovery', () => {
|
|
|
89
89
|
assert.deepEqual(resumed, [], 'a run that just moved is not swept')
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
+
test('backs off rather than re-resuming the same run every tick', async () => {
|
|
93
|
+
const resumes = trackResumes()
|
|
94
|
+
const ws = new InMemoryWorkflowService()
|
|
95
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
96
|
+
type: 'test',
|
|
97
|
+
})
|
|
98
|
+
await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
|
|
99
|
+
await backdate(ws, runId, 10 * 60_000)
|
|
100
|
+
|
|
101
|
+
const first = await ws.recoverStalledRuns()
|
|
102
|
+
// Still stalled: a resume does not clear whatever wedged the run, so an
|
|
103
|
+
// unconditional sweep would re-queue it on this tick and on every tick
|
|
104
|
+
// after it.
|
|
105
|
+
const second = await ws.recoverStalledRuns()
|
|
106
|
+
|
|
107
|
+
assert.deepEqual(first.resumed, [runId], 'the first sweep resumes it')
|
|
108
|
+
assert.deepEqual(
|
|
109
|
+
second.resumed,
|
|
110
|
+
[],
|
|
111
|
+
'the next sweep is held off by the backoff'
|
|
112
|
+
)
|
|
113
|
+
assert.deepEqual(resumes.runIds, [runId], 'only one queue message')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('does not re-drive a run the relay has just re-dispatched', async () => {
|
|
117
|
+
const resumes = trackResumes()
|
|
118
|
+
const ws = new InMemoryWorkflowService()
|
|
119
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
120
|
+
type: 'test',
|
|
121
|
+
})
|
|
122
|
+
// Old enough to read as both an undispatched step and a stalled run.
|
|
123
|
+
await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
|
|
124
|
+
await backdate(ws, runId, 10 * 60_000)
|
|
125
|
+
|
|
126
|
+
const relayed = await ws.relayUndispatchedSteps()
|
|
127
|
+
const swept = await ws.recoverStalledRuns()
|
|
128
|
+
|
|
129
|
+
assert.deepEqual(relayed.redispatched, [runId], 'the relay re-drives it')
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
swept.resumed,
|
|
132
|
+
[],
|
|
133
|
+
'the sweep adds nothing the relay has not already queued'
|
|
134
|
+
)
|
|
135
|
+
assert.deepEqual(resumes.runIds, [runId], 'only one queue message')
|
|
136
|
+
})
|
|
137
|
+
|
|
92
138
|
test('leaves a finished run alone', async () => {
|
|
93
139
|
trackResumes()
|
|
94
140
|
const ws = new InMemoryWorkflowService()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { WorkflowStepFunctionMismatchError } from './workflow-errors.js'
|
|
2
|
+
import type { StepState } from './workflow.types.js'
|
|
3
|
+
|
|
4
|
+
/** What claiming a step needs from the workflow service. */
|
|
5
|
+
export type StepClaimStore = {
|
|
6
|
+
getStepState(runId: string, stepName: string): Promise<StepState>
|
|
7
|
+
setStepRunning(stepId: string): Promise<void>
|
|
8
|
+
createRetryAttempt(
|
|
9
|
+
failedStepId: string,
|
|
10
|
+
status: 'pending' | 'running'
|
|
11
|
+
): Promise<StepState>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Decide whether this dispatch owns the step, by reading its state and then
|
|
16
|
+
* writing it — which only excludes a concurrent dispatch when the caller holds
|
|
17
|
+
* a lock that genuinely excludes one.
|
|
18
|
+
*
|
|
19
|
+
* A store that can express the whole decision as a single conditional write
|
|
20
|
+
* should do that instead of calling this.
|
|
21
|
+
*/
|
|
22
|
+
export const claimStepByReadThenWrite = async (
|
|
23
|
+
store: StepClaimStore,
|
|
24
|
+
runId: string,
|
|
25
|
+
stepName: string,
|
|
26
|
+
rpcName: string
|
|
27
|
+
): Promise<StepState | null> => {
|
|
28
|
+
const stepState = await store.getStepState(runId, stepName)
|
|
29
|
+
// knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
|
|
30
|
+
if (
|
|
31
|
+
stepState.rpcName !== undefined &&
|
|
32
|
+
stepState.rpcName !== (rpcName ?? null)
|
|
33
|
+
) {
|
|
34
|
+
throw new WorkflowStepFunctionMismatchError(runId, stepName)
|
|
35
|
+
}
|
|
36
|
+
if (stepState.status === 'succeeded' || stepState.status === 'running') {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
if (stepState.status === 'failed') {
|
|
40
|
+
return store.createRetryAttempt(stepState.stepId, 'running')
|
|
41
|
+
}
|
|
42
|
+
if (stepState.status === 'pending' || stepState.status === 'scheduled') {
|
|
43
|
+
await store.setStepRunning(stepState.stepId)
|
|
44
|
+
}
|
|
45
|
+
return stepState
|
|
46
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
5
|
+
import { pikkuState, resetPikkuState } from '../../pikku-state.js'
|
|
6
|
+
|
|
7
|
+
const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
|
|
8
|
+
|
|
9
|
+
/** Records every run lock taken, which is the whole point of the guard. */
|
|
10
|
+
class LockSpyWorkflowService extends InMemoryWorkflowService {
|
|
11
|
+
public readonly locked: string[] = []
|
|
12
|
+
|
|
13
|
+
public override async withRunLock<T>(
|
|
14
|
+
id: string,
|
|
15
|
+
fn: () => Promise<T>
|
|
16
|
+
): Promise<T> {
|
|
17
|
+
this.locked.push(id)
|
|
18
|
+
return super.withRunLock(id, fn)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A registered, fully described workflow, so nothing else can short-circuit. */
|
|
23
|
+
const startRun = async (): Promise<{
|
|
24
|
+
service: LockSpyWorkflowService
|
|
25
|
+
runId: string
|
|
26
|
+
entered: () => boolean
|
|
27
|
+
}> => {
|
|
28
|
+
resetPikkuState()
|
|
29
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
30
|
+
logger: silentLogger,
|
|
31
|
+
queueService: { add: async () => 'job-1' },
|
|
32
|
+
} as any)
|
|
33
|
+
|
|
34
|
+
let bodyEntered = false
|
|
35
|
+
const func = async () => {
|
|
36
|
+
bodyEntered = true
|
|
37
|
+
}
|
|
38
|
+
pikkuState(null, 'workflows', 'meta', {
|
|
39
|
+
flow: { name: 'flow', pikkuFuncId: 'flow', source: 'dsl' },
|
|
40
|
+
} as any)
|
|
41
|
+
pikkuState(null, 'workflows', 'registrations').set('flow', {
|
|
42
|
+
name: 'flow',
|
|
43
|
+
func,
|
|
44
|
+
} as any)
|
|
45
|
+
pikkuState(null, 'function', 'meta', {
|
|
46
|
+
flow: {
|
|
47
|
+
pikkuFuncId: 'flow',
|
|
48
|
+
inputSchemaName: null,
|
|
49
|
+
outputSchemaName: null,
|
|
50
|
+
sessionless: true,
|
|
51
|
+
},
|
|
52
|
+
} as any)
|
|
53
|
+
pikkuState(null, 'function', 'functions').set('flow', { func } as any)
|
|
54
|
+
|
|
55
|
+
const service = new LockSpyWorkflowService()
|
|
56
|
+
const runId = await service.createRun('flow', {}, false, '', { type: 'test' })
|
|
57
|
+
return { service, runId, entered: () => bodyEntered }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The leak this guards, read straight off production: every granted advisory
|
|
62
|
+
* lock held by an idle session mapped to a run that was already `failed`. The
|
|
63
|
+
* orchestrator queue is at-least-once, so a message for a settled run is
|
|
64
|
+
* routine — and answering it by taking the run lock and replaying the body is
|
|
65
|
+
* how a run that can never move again ends up holding a lock and a pooled
|
|
66
|
+
* connection while it waits on something that will never arrive.
|
|
67
|
+
*/
|
|
68
|
+
describe('an orchestrator message for a run that already settled', () => {
|
|
69
|
+
for (const status of ['failed', 'completed', 'cancelled'] as const) {
|
|
70
|
+
test(`a ${status} run is answered without taking its lock`, async () => {
|
|
71
|
+
const { service, runId, entered } = await startRun()
|
|
72
|
+
await service.updateRunStatus(runId, status)
|
|
73
|
+
|
|
74
|
+
await service.runWorkflowJob(runId, {} as any)
|
|
75
|
+
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
service.locked,
|
|
78
|
+
[],
|
|
79
|
+
'the run lock was taken for a run that can never move again'
|
|
80
|
+
)
|
|
81
|
+
assert.equal(
|
|
82
|
+
entered(),
|
|
83
|
+
false,
|
|
84
|
+
'the workflow body was replayed after the run had settled'
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Guards the tests above: they would pass just as well for a service that
|
|
91
|
+
* refused to orchestrate anything at all.
|
|
92
|
+
*/
|
|
93
|
+
test('a suspended run is still orchestrated', async () => {
|
|
94
|
+
const { service, runId } = await startRun()
|
|
95
|
+
await service.updateRunStatus(runId, 'suspended')
|
|
96
|
+
|
|
97
|
+
await service.runWorkflowJob(runId, {} as any)
|
|
98
|
+
|
|
99
|
+
assert.deepEqual(
|
|
100
|
+
service.locked,
|
|
101
|
+
[runId],
|
|
102
|
+
'suspended ends a pass, not the run — it resumes when its signal arrives'
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
})
|