@pikku/inspector 0.12.96 → 0.12.99
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 +53 -0
- package/dist/add/add-cli.js +4 -1
- package/dist/add/add-credential.js +2 -0
- package/dist/add/add-functions.js +39 -1
- package/dist/add/add-trigger.js +16 -0
- package/dist/add/add-workflow.js +0 -5
- package/dist/inspector.js +2 -1
- package/dist/utils/program-path.d.ts +7 -0
- package/dist/utils/program-path.js +8 -0
- package/dist/utils/workflow/derive-workflow-plan.js +6 -0
- package/dist/utils/workflow/dsl/deserialize-dsl-workflow.js +28 -43
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +36 -5
- package/dist/utils/workflow/dsl/patterns.d.ts +4 -0
- package/dist/utils/workflow/dsl/patterns.js +11 -0
- package/dist/utils/workflow/graph/convert-dsl-to-graph.js +12 -15
- package/dist/utils/workflow/graph/serialize-workflow-graph.d.ts +4 -2
- package/dist/utils/workflow/graph/serialize-workflow-graph.js +4 -2
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +5 -3
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,56 @@
|
|
|
1
|
+
## 0.12.99
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- ce3e5f5: Saga compensation for workflows. A function declares `compensate` inline; when a workflow step fails, completed steps are undone newest-first as durable `<step>:compensate` steps. New run statuses `compensating`, `compensated` and `compensation_failed`, `workflow.milestone(name)` to bound the unwind, `{ compensate: false }` to opt a call out, nested-workflow unwinding, and `PikkuWorkflowService.cancelRun` which unwinds too. Graph nodes replace `onError` with `recover` (`nodeId`, `nodeId[]` or `'ignore'`), exposing the error as `wire.graph.recoveringFrom`. **Breaking:** the DSL `onError` step option and the graph `onError` node field are removed; `getRunSteps` is now abstract on `PikkuWorkflowService`.
|
|
6
|
+
- Updated dependencies [ce3e5f5]
|
|
7
|
+
- Updated dependencies [fb36c37]
|
|
8
|
+
- @pikku/core@0.12.135
|
|
9
|
+
|
|
10
|
+
## 0.12.98
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 8ac25a8: An OAuth2 credential can set `scopeSeparator` for a provider that wants scopes joined by something other than a space (Twist wants a comma). A credential with no scopes already sent no `scope` parameter; that is now tested.
|
|
15
|
+
- 6606777: A webhook source can declare how its requests are signed, and the runner checks every request before `receive` runs:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
wireTriggerWebhookSource({
|
|
19
|
+
name: 'github',
|
|
20
|
+
verify: {
|
|
21
|
+
hmac: {
|
|
22
|
+
header: 'x-hub-signature-256',
|
|
23
|
+
prefix: 'sha256=',
|
|
24
|
+
algorithm: 'sha256',
|
|
25
|
+
encoding: 'hex',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
receive: githubWebhookReceive,
|
|
29
|
+
})
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`verify` is an HMAC over the raw body, a shared token or a public-key signature in one header, or a function `(request, secret, services) => boolean` for anything else. A request is refused while the secret is unset or when the signature does not match. A request without a body reaches `receive` unchecked so handshakes still work, but it may only be answered: events from it are refused.
|
|
33
|
+
|
|
34
|
+
Declaring `verify` declares the secret's credential too, so it needs no `defineCredential`. It is a singleton string named `<source>WebhookSecret` in camelCase (`microsoft-outlook` → `microsoftOutlookWebhookSecret`, see `webhookSecretCredentialName`), or whatever `credential` names, described by `credentialDescription`.
|
|
35
|
+
|
|
36
|
+
`@pikku/core/hmac` gains `hmacDigest`, `verifyHmacSignature` and `verifyPublicKeySignature`. `WebhookSigningSecret` is deprecated.
|
|
37
|
+
|
|
38
|
+
- Updated dependencies [d327fa5]
|
|
39
|
+
- Updated dependencies [942ebdd]
|
|
40
|
+
- Updated dependencies [8ac25a8]
|
|
41
|
+
- Updated dependencies [6606777]
|
|
42
|
+
- @pikku/core@0.12.132
|
|
43
|
+
|
|
44
|
+
## 0.12.97
|
|
45
|
+
|
|
46
|
+
### Patch Changes
|
|
47
|
+
|
|
48
|
+
- 26dbfc0: A `func` written inline in a `wireCLI` command now registers its function metadata under `cli:<program>:<command path>`, with `sessionless` taken from the helper it was built with. Before, the command pointed at an id nothing had registered. An inline channel `onMessage` still fails the build with the named "No function metadata found" error, and a test now pins that.
|
|
49
|
+
- 2dcc651: The inspector finds a project's source on Windows: its root directory is compared with TypeScript's forward-slash file names, not the backslash path `path.resolve` returns.
|
|
50
|
+
- Updated dependencies [58cccc1]
|
|
51
|
+
- Updated dependencies [a26c60e]
|
|
52
|
+
- @pikku/core@0.12.131
|
|
53
|
+
|
|
1
54
|
## 0.12.96
|
|
2
55
|
|
|
3
56
|
### Patch Changes
|
package/dist/add/add-cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { extractFunctionName, makeContextBasedId, } from '../utils/extract-funct
|
|
|
3
3
|
import { resolveCoreType } from '../utils/resolve-core-type.js';
|
|
4
4
|
import { resolveMiddleware } from '../utils/middleware.js';
|
|
5
5
|
import { resolveFunctionMeta } from '../utils/resolve-function-meta.js';
|
|
6
|
+
import { ensureInlineWiringFunction } from '../utils/ensure-function-metadata.js';
|
|
6
7
|
import { extractWireNames } from '../utils/post-process.js';
|
|
7
8
|
import { getPropertyValue } from '../utils/get-property-value.js';
|
|
8
9
|
import { resolveIdentifier } from '../utils/resolve-identifier.js';
|
|
@@ -253,9 +254,11 @@ function processCommand(logger, inspectorState, options, name, node, sourceFile,
|
|
|
253
254
|
}
|
|
254
255
|
}
|
|
255
256
|
else {
|
|
256
|
-
|
|
257
|
+
const extracted = extractFunctionName(prop.initializer, typeChecker, inspectorState.rootDir);
|
|
258
|
+
pikkuFuncId = extracted.pikkuFuncId;
|
|
257
259
|
if (pikkuFuncId.startsWith('__temp_')) {
|
|
258
260
|
pikkuFuncId = makeContextBasedId('cli', programName, ...fullPath);
|
|
261
|
+
ensureInlineWiringFunction(inspectorState, pikkuFuncId, fullPath.join(' '), prop.initializer, typeChecker, extracted.isHelper);
|
|
259
262
|
}
|
|
260
263
|
meta.pikkuFuncId = pikkuFuncId;
|
|
261
264
|
const cliPackageName = ts.isIdentifier(prop.initializer)
|
|
@@ -81,6 +81,7 @@ export const addCredential = (logger, node, checker, state, _options) => {
|
|
|
81
81
|
const authorizationUrl = getPropertyValue(oauth2Obj, 'authorizationUrl');
|
|
82
82
|
const tokenUrl = getPropertyValue(oauth2Obj, 'tokenUrl');
|
|
83
83
|
const scopes = getArrayPropertyValue(oauth2Obj, 'scopes');
|
|
84
|
+
const scopeSeparator = getPropertyValue(oauth2Obj, 'scopeSeparator');
|
|
84
85
|
const pkce = getPropertyValue(oauth2Obj, 'pkce');
|
|
85
86
|
const additionalParams = getRecordPropertyValue(oauth2Obj, 'additionalParams');
|
|
86
87
|
if (appCredentialSecretId && authorizationUrl && tokenUrl && scopes) {
|
|
@@ -90,6 +91,7 @@ export const addCredential = (logger, node, checker, state, _options) => {
|
|
|
90
91
|
authorizationUrl,
|
|
91
92
|
tokenUrl,
|
|
92
93
|
scopes,
|
|
94
|
+
scopeSeparator: scopeSeparator || undefined,
|
|
93
95
|
pkce: pkce || undefined,
|
|
94
96
|
additionalParams: additionalParams || undefined,
|
|
95
97
|
};
|
|
@@ -3,6 +3,7 @@ import { detectSchemaVendorOrError } from '../utils/detect-schema-vendor.js';
|
|
|
3
3
|
import { extractFunctionName, funcIdToTypeName, } from '../utils/extract-function-name.js';
|
|
4
4
|
import { extractFunctionNode } from '../utils/extract-function-node.js';
|
|
5
5
|
import { extractUsedWires } from '../utils/extract-services.js';
|
|
6
|
+
import { getPropertyAssignmentInitializer } from '../utils/type-utils.js';
|
|
6
7
|
import { collectInvokedRpcNames } from './add-rpc-invocations.js';
|
|
7
8
|
import { collectStartedWorkflows } from './collect-started-workflows.js';
|
|
8
9
|
import { formatVersionedId, parseVersionedId } from '@pikku/core/utils';
|
|
@@ -721,7 +722,43 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
721
722
|
services.optimized = false;
|
|
722
723
|
}
|
|
723
724
|
}
|
|
724
|
-
|
|
725
|
+
let wires = extractUsedWires(handler, 2);
|
|
726
|
+
// `compensate` runs as the function's own sibling step, deployed wherever the
|
|
727
|
+
// function is, so what it needs is what the function needs.
|
|
728
|
+
const compensateInitializer = objectNode
|
|
729
|
+
? getPropertyAssignmentInitializer(objectNode, 'compensate', true, checker)
|
|
730
|
+
: undefined;
|
|
731
|
+
const compensateNode = compensateInitializer &&
|
|
732
|
+
(ts.isArrowFunction(compensateInitializer) ||
|
|
733
|
+
ts.isFunctionExpression(compensateInitializer)
|
|
734
|
+
? compensateInitializer
|
|
735
|
+
: undefined);
|
|
736
|
+
if (compensateNode) {
|
|
737
|
+
const compensateServices = compensateNode.parameters[0];
|
|
738
|
+
if (compensateServices) {
|
|
739
|
+
if (ts.isObjectBindingPattern(compensateServices.name)) {
|
|
740
|
+
for (const elem of compensateServices.name.elements) {
|
|
741
|
+
const original = elem.propertyName && ts.isIdentifier(elem.propertyName)
|
|
742
|
+
? elem.propertyName.text
|
|
743
|
+
: ts.isIdentifier(elem.name)
|
|
744
|
+
? elem.name.text
|
|
745
|
+
: undefined;
|
|
746
|
+
if (original && !services.services.includes(original)) {
|
|
747
|
+
services.services.push(original);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
else if (ts.isIdentifier(compensateServices.name) &&
|
|
752
|
+
!compensateServices.name.text.startsWith('_')) {
|
|
753
|
+
services.optimized = false;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const compensateWires = extractUsedWires(compensateNode, 2);
|
|
757
|
+
wires = {
|
|
758
|
+
optimized: wires.optimized && compensateWires.optimized,
|
|
759
|
+
wires: [...new Set([...wires.wires, ...compensateWires.wires])],
|
|
760
|
+
};
|
|
761
|
+
}
|
|
725
762
|
const invoked = collectInvokedRpcNames(handler.body);
|
|
726
763
|
for (const call of invoked.dynamic) {
|
|
727
764
|
logger.warn(`• ${pikkuFuncId} calls ${call} with a computed name — the deploy planner cannot bind its unit to the callee's, so the call fails with "No service binding" if the two are deployed apart. Use a literal RPC name.`);
|
|
@@ -1031,6 +1068,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
1031
1068
|
workflowQueued: workflowQueued === true ? true : undefined,
|
|
1032
1069
|
workflowRetries: workflowRetries ?? undefined,
|
|
1033
1070
|
workflowTimeout: workflowTimeout ?? undefined,
|
|
1071
|
+
compensate: compensateInitializer ? true : undefined,
|
|
1034
1072
|
scenarioStepSurfaces,
|
|
1035
1073
|
scenarioStepRequiresActor,
|
|
1036
1074
|
// `persona` is the default reading of an unmarked step, so it is left off
|
package/dist/add/add-trigger.js
CHANGED
|
@@ -8,6 +8,7 @@ import { extractWireNames } from '../utils/post-process.js';
|
|
|
8
8
|
import { resolveAddonName } from '../utils/resolve-addon-package.js';
|
|
9
9
|
import { resolveFunctionMeta } from '../utils/resolve-function-meta.js';
|
|
10
10
|
import { ErrorCode } from '../error-codes.js';
|
|
11
|
+
import { webhookSecretCredentialName } from '@pikku/core/trigger';
|
|
11
12
|
const WEBHOOK_SOURCE_STEPS = ['receive', 'check', 'setup', 'teardown'];
|
|
12
13
|
export const addTrigger = (logger, node, checker, state, options) => {
|
|
13
14
|
if (!ts.isCallExpression(node)) {
|
|
@@ -161,6 +162,21 @@ const addWireTriggerWebhookSource = (logger, node, checker, state, firstArg) =>
|
|
|
161
162
|
};
|
|
162
163
|
state.triggers.webhookSourceMeta[name] = meta;
|
|
163
164
|
state.triggers.files.add(node.getSourceFile().fileName);
|
|
165
|
+
if (!getPropertyAssignmentInitializer(obj, 'verify', false, checker))
|
|
166
|
+
return;
|
|
167
|
+
const sourceFile = node.getSourceFile().fileName;
|
|
168
|
+
const credential = getPropertyValue(obj, 'credential') ??
|
|
169
|
+
webhookSecretCredentialName(name);
|
|
170
|
+
state.credentials.files.add(sourceFile);
|
|
171
|
+
state.credentials.definitions.push({
|
|
172
|
+
name: credential,
|
|
173
|
+
displayName: `${name} webhook secret`,
|
|
174
|
+
description: getPropertyValue(obj, 'credentialDescription') ??
|
|
175
|
+
undefined,
|
|
176
|
+
type: 'singleton',
|
|
177
|
+
schema: { type: 'string' },
|
|
178
|
+
sourceFile,
|
|
179
|
+
});
|
|
164
180
|
};
|
|
165
181
|
/**
|
|
166
182
|
* A trigger named `<source>:<event>` must name an event its webhook source
|
package/dist/add/add-workflow.js
CHANGED
|
@@ -80,11 +80,6 @@ export function collectInvokedRPCs(steps, rpcs) {
|
|
|
80
80
|
for (const step of steps) {
|
|
81
81
|
if (step.type === 'rpc' && step.rpcName) {
|
|
82
82
|
rpcs.add(step.rpcName);
|
|
83
|
-
// The compensation handler is invoked at runtime too, so it has to be
|
|
84
|
-
// wired like any other step.
|
|
85
|
-
if (step.options?.onError) {
|
|
86
|
-
rpcs.add(step.options.onError);
|
|
87
|
-
}
|
|
88
83
|
}
|
|
89
84
|
else if (step.type === 'scenarioStep' && step.stepFunc) {
|
|
90
85
|
// Steps are bundled like RPCs — the runner calls them through
|
package/dist/inspector.js
CHANGED
|
@@ -7,6 +7,7 @@ import { getFilesAndMethods } from './utils/get-files-and-methods.js';
|
|
|
7
7
|
import { findCommonAncestor } from './utils/find-root-dir.js';
|
|
8
8
|
import { createSourceFileCacheHost } from './source-file-cache.js';
|
|
9
9
|
import { createNestedProjectFilter } from './utils/nested-project-filter.js';
|
|
10
|
+
import { toProgramPath } from './utils/program-path.js';
|
|
10
11
|
import { aggregateRequiredServices, stampAuthHandlerServices, validateAgentModels, validateAgentToolReferences, validateAgentScorerReferences, validateSecretOverrides, validateVariableOverrides, validateCredentialOverrides, validateRemoteAddonDependencies, validateRemoteAddonAuth, validateScopeReferences, validateSystemRoleScopes, validatePersonaRoles, computeResolvedIOTypes, computeMiddlewareGroupsMeta, computePermissionsGroupsMeta, computeRequiredSchemas, validateNoSecretAliasServices, registerDerivedOAuth2AppSecrets, validateSecretUsage, computeDiagnostics, validateSchemaReferences, validateSchemaWiringSeparation, validateScenarioServices, validateScenarioSteps, validateScenarioFeatures, validateWorkflowGraphAddons, } from './utils/post-process.js';
|
|
11
12
|
import { validateExposedFunctionsGated } from './utils/validate-exposed-functions-gated.js';
|
|
12
13
|
import { validateTagsResolveToMiddleware } from './utils/validate-tags-resolve-to-middleware.js';
|
|
@@ -293,7 +294,7 @@ export const inspect = async (logger, routeFiles, options = {}) => {
|
|
|
293
294
|
const checker = program.getTypeChecker();
|
|
294
295
|
logger.debug(`Got type checker in ${(performance.now() - startChecker).toFixed(2)}ms`);
|
|
295
296
|
// Use provided rootDir or infer from source files
|
|
296
|
-
const rootDir = options.rootDir || findCommonAncestor(normalizedRouteFiles);
|
|
297
|
+
const rootDir = toProgramPath(options.rootDir || findCommonAncestor(normalizedRouteFiles));
|
|
297
298
|
const startSourceFiles = performance.now();
|
|
298
299
|
// node_modules under rootDir (e.g. a locally-installed addon) is a
|
|
299
300
|
// dependency, not project source — scanning it double-counts the addon's
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A native path in the form a TypeScript program names its files: forward
|
|
3
|
+
* slashes on every platform. `sourceFile.fileName` is `D:/a/app/src/x.ts` on
|
|
4
|
+
* Windows while `path.resolve` gives `D:\a\app`, so comparing the two by
|
|
5
|
+
* prefix rejects every file unless one side is converted.
|
|
6
|
+
*/
|
|
7
|
+
export declare const toProgramPath: (nativePath: string, separator?: "/" | "\\") => string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { sep } from 'path';
|
|
2
|
+
/**
|
|
3
|
+
* A native path in the form a TypeScript program names its files: forward
|
|
4
|
+
* slashes on every platform. `sourceFile.fileName` is `D:/a/app/src/x.ts` on
|
|
5
|
+
* Windows while `path.resolve` gives `D:\a\app`, so comparing the two by
|
|
6
|
+
* prefix rejects every file unless one side is converted.
|
|
7
|
+
*/
|
|
8
|
+
export const toProgramPath = (nativePath, separator = sep) => nativePath.split(separator).join('/');
|
|
@@ -70,6 +70,12 @@ function collectNamedSteps(steps) {
|
|
|
70
70
|
displayName: reasonToLabel(step.reason),
|
|
71
71
|
});
|
|
72
72
|
break;
|
|
73
|
+
case 'milestone':
|
|
74
|
+
planned.push({
|
|
75
|
+
stepName: `__milestone__:${step.name}`,
|
|
76
|
+
displayName: reasonToLabel(step.name),
|
|
77
|
+
});
|
|
78
|
+
break;
|
|
73
79
|
case 'approval':
|
|
74
80
|
// Same as suspend, but namespaced `__workflow_approval:` to match the
|
|
75
81
|
// runtime's separate step key.
|
|
@@ -142,7 +142,9 @@ function fanoutBodyToCode(entryNodeId, nodes, indent, itemVar) {
|
|
|
142
142
|
const stepName = node.stepName || `Call ${node.rpcName}`;
|
|
143
143
|
const input = (node.input || {});
|
|
144
144
|
const inputCode = inputToCode(input, indent, itemVar);
|
|
145
|
-
const options = node.options
|
|
145
|
+
const options = node.options || node.compensate === false
|
|
146
|
+
? optionsToCode(node.options ?? {}, node.compensate === false ? false : undefined)
|
|
147
|
+
: '';
|
|
146
148
|
const doCall = `await workflow.do('${escapeSingleQuotes(stepName)}', '${node.rpcName}', ${inputCode}${options ? `, ${options}` : ''})`;
|
|
147
149
|
lines.push(node.outputVar
|
|
148
150
|
? `${indent}const ${node.outputVar} = ${doCall}`
|
|
@@ -163,19 +165,6 @@ function fanoutBodyToCode(entryNodeId, nodes, indent, itemVar) {
|
|
|
163
165
|
*/
|
|
164
166
|
function collectChildNodeIds(nodes) {
|
|
165
167
|
const owned = new Set();
|
|
166
|
-
// An error handler belongs to the step that routes to it, not to the main
|
|
167
|
-
// flow — emitting it top-level would turn compensation into a step that
|
|
168
|
-
// always runs.
|
|
169
|
-
for (const node of Object.values(nodes)) {
|
|
170
|
-
const onError = node.onError;
|
|
171
|
-
if (typeof onError === 'string') {
|
|
172
|
-
owned.add(onError);
|
|
173
|
-
}
|
|
174
|
-
else if (Array.isArray(onError)) {
|
|
175
|
-
for (const handlerId of onError)
|
|
176
|
-
owned.add(handlerId);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
168
|
const walk = (entry, exit) => {
|
|
180
169
|
const seen = new Set();
|
|
181
170
|
let currentId = entry;
|
|
@@ -231,20 +220,6 @@ function bindingPrefix(outputVar) {
|
|
|
231
220
|
? `const ${outputVar} = `
|
|
232
221
|
: '';
|
|
233
222
|
}
|
|
234
|
-
/**
|
|
235
|
-
* Resolve a node's `onError` node id back to the rpc name the DSL author wrote.
|
|
236
|
-
*/
|
|
237
|
-
function onErrorRpcNameOf(node, nodes) {
|
|
238
|
-
const onError = node.onError;
|
|
239
|
-
const handlerId = Array.isArray(onError) ? onError[0] : onError;
|
|
240
|
-
if (typeof handlerId !== 'string') {
|
|
241
|
-
return undefined;
|
|
242
|
-
}
|
|
243
|
-
const handler = nodes[handlerId];
|
|
244
|
-
return handler && 'rpcName' in handler
|
|
245
|
-
? handler.rpcName
|
|
246
|
-
: undefined;
|
|
247
|
-
}
|
|
248
223
|
/**
|
|
249
224
|
* A duration is `string | number` ('5s' or 5000). Quoting a number would change
|
|
250
225
|
* the value the runtime parses, so only strings get quotes.
|
|
@@ -267,17 +242,17 @@ function caseValueToCode(value) {
|
|
|
267
242
|
/**
|
|
268
243
|
* Convert options to code
|
|
269
244
|
*/
|
|
270
|
-
function optionsToCode(options,
|
|
245
|
+
function optionsToCode(options, compensate) {
|
|
271
246
|
const parts = [];
|
|
272
|
-
if (onErrorRpcName) {
|
|
273
|
-
parts.push(`onError: '${escapeSingleQuotes(onErrorRpcName)}'`);
|
|
274
|
-
}
|
|
275
247
|
if (options.retries !== undefined) {
|
|
276
248
|
parts.push(`retries: ${options.retries}`);
|
|
277
249
|
}
|
|
278
250
|
if (options.retryDelay !== undefined) {
|
|
279
251
|
parts.push(`retryDelay: ${durationToCode(options.retryDelay)}`);
|
|
280
252
|
}
|
|
253
|
+
if (compensate === false) {
|
|
254
|
+
parts.push('compensate: false');
|
|
255
|
+
}
|
|
281
256
|
return parts.length > 0 ? `{ ${parts.join(', ')} }` : '';
|
|
282
257
|
}
|
|
283
258
|
/**
|
|
@@ -422,9 +397,9 @@ function nodeToCode(node, nodes, indent, conditionalVars = new Set(), isInsideBr
|
|
|
422
397
|
const outputVar = node.outputVar;
|
|
423
398
|
let doCall = `await workflow.do('${stepName}', '${node.rpcName}', ${inputCode}`;
|
|
424
399
|
// Add options if present
|
|
425
|
-
const
|
|
426
|
-
if (node.options ||
|
|
427
|
-
const optCode = optionsToCode(node.options ?? {},
|
|
400
|
+
const noCompensate = node.compensate === false ? false : undefined;
|
|
401
|
+
if (node.options || noCompensate === false) {
|
|
402
|
+
const optCode = optionsToCode(node.options ?? {}, noCompensate);
|
|
428
403
|
if (optCode) {
|
|
429
404
|
doCall += `, ${optCode}`;
|
|
430
405
|
}
|
|
@@ -459,6 +434,10 @@ function nodeToCode(node, nodes, indent, conditionalVars = new Set(), isInsideBr
|
|
|
459
434
|
lines.push(`${indent}await workflow.suspend('${escapeSingleQuotes(String(flowNode.reason ?? ''))}')`);
|
|
460
435
|
lines.push('');
|
|
461
436
|
break;
|
|
437
|
+
case 'milestone':
|
|
438
|
+
lines.push(`${indent}await workflow.milestone('${escapeSingleQuotes(String(flowNode.reason ?? ''))}')`);
|
|
439
|
+
lines.push('');
|
|
440
|
+
break;
|
|
462
441
|
case 'approval': {
|
|
463
442
|
const approvalCall = `await workflow.approval('${escapeSingleQuotes(String(flowNode.reason ?? ''))}')`;
|
|
464
443
|
lines.push(flowNode.outputVar
|
|
@@ -537,8 +516,8 @@ function nodeToCode(node, nodes, indent, conditionalVars = new Set(), isInsideBr
|
|
|
537
516
|
const input = (childNode.input || {});
|
|
538
517
|
const inputCode = inputToCode(input, indent + ' ');
|
|
539
518
|
let call = `workflow.do('${stepName}', '${childNode.rpcName}', ${inputCode}`;
|
|
540
|
-
if (childNode.options) {
|
|
541
|
-
const optCode = optionsToCode(childNode.options);
|
|
519
|
+
if (childNode.options || childNode.compensate === false) {
|
|
520
|
+
const optCode = optionsToCode(childNode.options ?? {}, childNode.compensate === false ? false : undefined);
|
|
542
521
|
if (optCode) {
|
|
543
522
|
call += `, ${optCode}`;
|
|
544
523
|
}
|
|
@@ -1021,14 +1000,20 @@ export function deserializeGraphWorkflow(workflow, options = {}) {
|
|
|
1021
1000
|
configParts.push(`next: ${nextCode}`);
|
|
1022
1001
|
}
|
|
1023
1002
|
}
|
|
1024
|
-
//
|
|
1025
|
-
// so dropping them here silently changes how the workflow
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1003
|
+
// recover, compensate, retries and retryDelay are all honoured by the
|
|
1004
|
+
// graph runner, so dropping them here silently changes how the workflow
|
|
1005
|
+
// behaves.
|
|
1006
|
+
if ('recover' in node && node.recover) {
|
|
1007
|
+
const recoverCode = node.recover === 'ignore'
|
|
1008
|
+
? `'ignore'`
|
|
1009
|
+
: nextToCode(node.recover, workflow.nodes, flowNodeIds);
|
|
1010
|
+
if (recoverCode) {
|
|
1011
|
+
configParts.push(`recover: ${recoverCode}`);
|
|
1030
1012
|
}
|
|
1031
1013
|
}
|
|
1014
|
+
if ('compensate' in node && node.compensate === false) {
|
|
1015
|
+
configParts.push('compensate: false');
|
|
1016
|
+
}
|
|
1032
1017
|
if ('options' in node && node.options) {
|
|
1033
1018
|
const { retries, retryDelay } = node.options;
|
|
1034
1019
|
if (retries !== undefined) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
|
-
import { isWorkflowDoCall, isWorkflowExpectEventuallyCall, isScenarioStepCall, isScenarioExpectationCall, getScenarioStepPhase, DYNAMIC_SCENARIO_STEP_TARGET, extractActorFromOptions, isWorkflowSleepCall, isWorkflowSuspendCall, isWorkflowApprovalCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
|
|
2
|
+
import { isWorkflowDoCall, isWorkflowExpectEventuallyCall, isScenarioStepCall, isScenarioExpectationCall, getScenarioStepPhase, DYNAMIC_SCENARIO_STEP_TARGET, extractActorFromOptions, isWorkflowSleepCall, isWorkflowSuspendCall, isWorkflowMilestoneCall, isWorkflowApprovalCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
|
|
3
3
|
import { validateNoDisallowedPatterns, validateAwaitedCalls, formatValidationErrors, } from './validation.js';
|
|
4
4
|
import { extractStringLiteral, extractNumberLiteral, } from '../../extract-node-value.js';
|
|
5
5
|
/**
|
|
@@ -518,6 +518,9 @@ function extractExpressionStatement(statement, context) {
|
|
|
518
518
|
if (isWorkflowSuspendCall(call, context.checker)) {
|
|
519
519
|
return extractSuspendStep(call, context);
|
|
520
520
|
}
|
|
521
|
+
if (isWorkflowMilestoneCall(call, context.checker)) {
|
|
522
|
+
return extractMilestoneStep(call, context);
|
|
523
|
+
}
|
|
521
524
|
if (isWorkflowApprovalCall(call, context.checker)) {
|
|
522
525
|
const step = extractApprovalStep(call, context, outputVar);
|
|
523
526
|
// Unlike suspend, an approval yields a value, so a downstream step can
|
|
@@ -696,13 +699,13 @@ function extractStepOptions(optionsNode, context) {
|
|
|
696
699
|
// Ignore extraction errors for retryDelay
|
|
697
700
|
}
|
|
698
701
|
}
|
|
699
|
-
else if (propName === '
|
|
700
|
-
if (ts.
|
|
701
|
-
options.
|
|
702
|
+
else if (propName === 'compensate') {
|
|
703
|
+
if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword) {
|
|
704
|
+
options.compensate = false;
|
|
702
705
|
}
|
|
703
706
|
else {
|
|
704
707
|
context.errors.push({
|
|
705
|
-
message:
|
|
708
|
+
message: "compensate on a call site can only be `false`: a step's compensation is declared on the function itself.",
|
|
706
709
|
node: prop.initializer,
|
|
707
710
|
});
|
|
708
711
|
}
|
|
@@ -786,6 +789,27 @@ function extractSuspendStep(call, context) {
|
|
|
786
789
|
return null;
|
|
787
790
|
}
|
|
788
791
|
}
|
|
792
|
+
/**
|
|
793
|
+
* Extract milestone step from workflow.milestone() call
|
|
794
|
+
*/
|
|
795
|
+
function extractMilestoneStep(call, context) {
|
|
796
|
+
const args = call.arguments;
|
|
797
|
+
if (args.length < 1)
|
|
798
|
+
return null;
|
|
799
|
+
try {
|
|
800
|
+
return {
|
|
801
|
+
type: 'milestone',
|
|
802
|
+
name: extractStringLiteral(args[0], context.checker),
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
catch (error) {
|
|
806
|
+
context.errors.push({
|
|
807
|
+
message: `Failed to extract milestone step: ${error instanceof Error ? error.message : String(error)}`,
|
|
808
|
+
node: call,
|
|
809
|
+
});
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
789
813
|
/**
|
|
790
814
|
* Extract approval step from workflow.approval() call
|
|
791
815
|
*/
|
|
@@ -1146,6 +1170,13 @@ function extractFanoutBodyStep(stmt, childContext, body) {
|
|
|
1146
1170
|
}
|
|
1147
1171
|
return;
|
|
1148
1172
|
}
|
|
1173
|
+
if (isWorkflowMilestoneCall(call, childContext.checker)) {
|
|
1174
|
+
const milestoneStep = extractMilestoneStep(call, childContext);
|
|
1175
|
+
if (milestoneStep) {
|
|
1176
|
+
body.push(milestoneStep);
|
|
1177
|
+
}
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1149
1180
|
if (isScenarioStepCall(call)) {
|
|
1150
1181
|
const scenarioStep = extractScenarioStep(call, childContext);
|
|
1151
1182
|
if (scenarioStep) {
|
|
@@ -57,6 +57,10 @@ export declare function isWorkflowSleepCall(node: ts.CallExpression, checker: ts
|
|
|
57
57
|
* Check if a call expression is workflow.suspend()
|
|
58
58
|
*/
|
|
59
59
|
export declare function isWorkflowSuspendCall(node: ts.CallExpression, _checker: ts.TypeChecker): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Check if a call expression is workflow.milestone()
|
|
62
|
+
*/
|
|
63
|
+
export declare function isWorkflowMilestoneCall(node: ts.CallExpression, _checker: ts.TypeChecker): boolean;
|
|
60
64
|
/**
|
|
61
65
|
* Check if a call expression is workflow.approval()
|
|
62
66
|
*/
|
|
@@ -133,6 +133,17 @@ export function isWorkflowSuspendCall(node, _checker) {
|
|
|
133
133
|
return (propAccess.name.text === 'suspend' &&
|
|
134
134
|
isWorkflowWireIdentifier(propAccess.expression));
|
|
135
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Check if a call expression is workflow.milestone()
|
|
138
|
+
*/
|
|
139
|
+
export function isWorkflowMilestoneCall(node, _checker) {
|
|
140
|
+
if (!ts.isPropertyAccessExpression(node.expression)) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
const propAccess = node.expression;
|
|
144
|
+
return (propAccess.name.text === 'milestone' &&
|
|
145
|
+
isWorkflowWireIdentifier(propAccess.expression));
|
|
146
|
+
}
|
|
136
147
|
/**
|
|
137
148
|
* Check if a call expression is workflow.approval()
|
|
138
149
|
*/
|
|
@@ -85,21 +85,9 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
|
|
|
85
85
|
retries: step.options.retries,
|
|
86
86
|
retryDelay: step.options.retryDelay,
|
|
87
87
|
};
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// draw the error edge to, exactly as in a hand-authored graph.
|
|
92
|
-
if (step.options?.onError) {
|
|
93
|
-
const handlerId = `${nodeId}_onError`;
|
|
94
|
-
node.onError = handlerId;
|
|
95
|
-
return [
|
|
96
|
-
node,
|
|
97
|
-
{
|
|
98
|
-
nodeId: handlerId,
|
|
99
|
-
rpcName: step.options.onError,
|
|
100
|
-
stepName: `${step.stepName} (on error)`,
|
|
101
|
-
},
|
|
102
|
-
];
|
|
88
|
+
if (step.options.compensate === false) {
|
|
89
|
+
node.compensate = false;
|
|
90
|
+
}
|
|
103
91
|
}
|
|
104
92
|
return [node];
|
|
105
93
|
}
|
|
@@ -161,6 +149,15 @@ function convertStepToNode(step, index, steps, nodeIdPrefix = 'step') {
|
|
|
161
149
|
};
|
|
162
150
|
return [node];
|
|
163
151
|
}
|
|
152
|
+
case 'milestone': {
|
|
153
|
+
const node = {
|
|
154
|
+
nodeId,
|
|
155
|
+
flow: 'milestone',
|
|
156
|
+
reason: step.name,
|
|
157
|
+
next: nextNodeId,
|
|
158
|
+
};
|
|
159
|
+
return [node];
|
|
160
|
+
}
|
|
164
161
|
case 'approval': {
|
|
165
162
|
// Unlike `suspend` — which has no output and so is deliberately absent
|
|
166
163
|
// from the graph — an approval yields a value a later step can reference,
|
|
@@ -18,7 +18,8 @@ export declare function serializeWorkflowGraph(definition: {
|
|
|
18
18
|
} | ((ref: any) => unknown);
|
|
19
19
|
mode?: 'parallel' | 'sequential';
|
|
20
20
|
next?: string | string[] | Record<string, string | string[]>;
|
|
21
|
-
|
|
21
|
+
recover?: string | string[] | 'ignore';
|
|
22
|
+
compensate?: false;
|
|
22
23
|
notes?: string;
|
|
23
24
|
}>;
|
|
24
25
|
}, options?: {
|
|
@@ -36,7 +37,8 @@ export declare function deserializeWorkflowGraph(serialized: SerializedWorkflowG
|
|
|
36
37
|
rpcName: string;
|
|
37
38
|
input: Record<string, unknown | DataRef>;
|
|
38
39
|
next?: SerializedNext;
|
|
39
|
-
|
|
40
|
+
recover?: string | string[] | 'ignore';
|
|
41
|
+
compensate?: false;
|
|
40
42
|
}>;
|
|
41
43
|
entryNodeIds: string[];
|
|
42
44
|
};
|
|
@@ -115,7 +115,8 @@ export function serializeWorkflowGraph(definition, options) {
|
|
|
115
115
|
rpcName,
|
|
116
116
|
input,
|
|
117
117
|
next: serializeNext(node.next),
|
|
118
|
-
|
|
118
|
+
recover: node.recover,
|
|
119
|
+
compensate: node.compensate,
|
|
119
120
|
};
|
|
120
121
|
const forEach = serializeForEach(node.forEach, createRef);
|
|
121
122
|
if (forEach) {
|
|
@@ -158,7 +159,8 @@ export function deserializeWorkflowGraph(serialized) {
|
|
|
158
159
|
rpcName: funcNode.rpcName,
|
|
159
160
|
input: funcNode.input ?? {},
|
|
160
161
|
next: funcNode.next,
|
|
161
|
-
|
|
162
|
+
recover: funcNode.recover,
|
|
163
|
+
compensate: funcNode.compensate,
|
|
162
164
|
};
|
|
163
165
|
}
|
|
164
166
|
}
|
|
@@ -66,7 +66,7 @@ export interface NodeOptions {
|
|
|
66
66
|
/**
|
|
67
67
|
* Flow node types for control flow (no RPC call)
|
|
68
68
|
*/
|
|
69
|
-
export type FlowType = 'sleep' | 'suspend' | 'approval' | 'branch' | 'parallel' | 'fanout' | 'inline' | 'switch' | 'filter' | 'arrayPredicate' | 'return' | 'cancel' | 'set';
|
|
69
|
+
export type FlowType = 'sleep' | 'suspend' | 'milestone' | 'approval' | 'branch' | 'parallel' | 'fanout' | 'inline' | 'switch' | 'filter' | 'arrayPredicate' | 'return' | 'cancel' | 'set';
|
|
70
70
|
import type { ContextVariable, WorkflowContext, WorkflowPlannedStep } from '@pikku/core/workflow';
|
|
71
71
|
import type { ScenarioStepPhase } from '@pikku/core/scenario';
|
|
72
72
|
export type { ContextVariable, WorkflowContext };
|
|
@@ -80,8 +80,10 @@ interface BaseNode {
|
|
|
80
80
|
stepName?: string;
|
|
81
81
|
/** Next node(s) - simple, parallel, or conditional */
|
|
82
82
|
next?: SerializedNext;
|
|
83
|
-
/**
|
|
84
|
-
|
|
83
|
+
/** What to do when this node fails: run these nodes, or 'ignore' to carry on. */
|
|
84
|
+
recover?: string | string[] | 'ignore';
|
|
85
|
+
/** Leave this node out of the saga unwind. */
|
|
86
|
+
compensate?: false;
|
|
85
87
|
/** Execution options */
|
|
86
88
|
options?: NodeOptions;
|
|
87
89
|
/** Free-text node documentation. Non-semantic — excluded from graphHash. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.99",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/pikkujs/pikku.git",
|
|
@@ -54,10 +54,10 @@
|
|
|
54
54
|
"zod": "^4.4.3"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
|
-
"@pikku/core": "^0.12.
|
|
57
|
+
"@pikku/core": "^0.12.135"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@pikku/core": "^0.12.
|
|
60
|
+
"@pikku/core": "^0.12.135",
|
|
61
61
|
"@types/node": "^24.13.3"
|
|
62
62
|
},
|
|
63
63
|
"engines": {
|