@pikku/core 0.12.63 → 0.12.66
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 +368 -0
- package/dist/dev/hot-reload.js +13 -55
- package/dist/dev/module-runner.d.ts +14 -0
- package/dist/dev/module-runner.js +59 -0
- package/dist/dev/reload-meta.d.ts +13 -0
- package/dist/dev/reload-meta.js +22 -0
- package/dist/errors/errors.d.ts +12 -0
- package/dist/errors/errors.js +19 -0
- package/dist/function/function-runner.d.ts +5 -5
- package/dist/function/function-runner.js +39 -111
- package/dist/function/functions.types.d.ts +17 -7
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/middleware/auth-apikey.d.ts +1 -0
- package/dist/middleware/auth-bearer.d.ts +1 -0
- package/dist/middleware/auth-cookie.d.ts +1 -0
- package/dist/middleware/cors.d.ts +1 -0
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/remote-auth.d.ts +1 -0
- package/dist/middleware/telemetry.d.ts +2 -0
- package/dist/middleware/timeout.d.ts +1 -0
- package/dist/permissions.d.ts +42 -27
- package/dist/permissions.js +101 -203
- package/dist/pikku-state.js +0 -2
- package/dist/scopes.d.ts +17 -0
- package/dist/scopes.js +59 -0
- package/dist/services/ai-embedding-service.d.ts +31 -0
- package/dist/services/ai-embedding-service.js +1 -0
- package/dist/services/credential-wire-service.d.ts +3 -1
- package/dist/services/credential-wire-service.js +10 -4
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.js +2 -0
- package/dist/services/meta-service.d.ts +15 -2
- package/dist/services/meta-service.js +51 -1
- package/dist/services/queue-webhook-service.d.ts +55 -0
- package/dist/services/queue-webhook-service.js +136 -0
- package/dist/services/scope-service.d.ts +67 -0
- package/dist/services/scope-service.js +1 -0
- package/dist/services/typed-secret-service.d.ts +8 -0
- package/dist/services/typed-secret-service.js +39 -4
- package/dist/services/webhook-service.d.ts +140 -0
- package/dist/services/webhook-service.js +44 -0
- package/dist/services/workflow-service.d.ts +7 -0
- package/dist/testing/service-tests.js +37 -0
- package/dist/types/core.types.d.ts +34 -6
- package/dist/types/state.types.d.ts +20 -2
- package/dist/utils/hmac.d.ts +16 -0
- package/dist/utils/hmac.js +26 -0
- package/dist/utils/safe-fetch.d.ts +51 -0
- package/dist/utils/safe-fetch.js +192 -0
- package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
- package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +105 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +249 -11
- package/dist/wirings/ai-agent/ai-agent-runner.js +10 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +88 -16
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +56 -2
- package/dist/wirings/ai-agent/index.d.ts +2 -1
- package/dist/wirings/ai-agent/index.js +2 -1
- package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
- package/dist/wirings/ai-agent/voice-input.js +5 -41
- package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
- package/dist/wirings/channel/channel-common.js +0 -1
- package/dist/wirings/channel/channel-handler.js +1 -4
- package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
- package/dist/wirings/channel/channel-middleware-runner.js +34 -19
- package/dist/wirings/channel/channel.types.d.ts +2 -6
- package/dist/wirings/cli/cli-runner.js +3 -2
- package/dist/wirings/cli/cli.types.d.ts +1 -2
- package/dist/wirings/cli/command-parser.d.ts +2 -0
- package/dist/wirings/cli/command-parser.js +59 -2
- package/dist/wirings/credential/credential.types.d.ts +14 -0
- package/dist/wirings/credential/validate-credential-definitions.js +1 -0
- package/dist/wirings/gateway/gateway-runner.js +100 -50
- package/dist/wirings/gateway/gateway.types.d.ts +8 -5
- package/dist/wirings/http/http-routes.js +0 -3
- package/dist/wirings/http/http-runner.d.ts +9 -29
- package/dist/wirings/http/http-runner.js +9 -34
- package/dist/wirings/http/http.types.d.ts +4 -12
- package/dist/wirings/mcp/mcp-runner.js +0 -2
- package/dist/wirings/mcp/mcp.types.d.ts +5 -11
- package/dist/wirings/oauth2/index.d.ts +0 -3
- package/dist/wirings/oauth2/index.js +1 -2
- package/dist/wirings/rpc/addon-runner.d.ts +28 -0
- package/dist/wirings/rpc/addon-runner.js +173 -0
- package/dist/wirings/rpc/index.d.ts +5 -1
- package/dist/wirings/rpc/index.js +3 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
- package/dist/wirings/rpc/remote-addon-auth.js +43 -0
- package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
- package/dist/wirings/rpc/rpc-runner.js +109 -3
- package/dist/wirings/rpc/rpc-types.d.ts +15 -1
- package/dist/wirings/rpc/wire-addon.js +9 -0
- package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
- package/dist/wirings/rpc/wire-remote-addon.js +19 -0
- package/dist/wirings/scope/index.d.ts +3 -0
- package/dist/wirings/scope/index.js +2 -0
- package/dist/wirings/scope/scope.types.d.ts +39 -0
- package/dist/wirings/scope/scope.types.js +1 -0
- package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
- package/dist/wirings/scope/validate-scope-definitions.js +76 -0
- package/dist/wirings/scope/wire-scope.d.ts +33 -0
- package/dist/wirings/scope/wire-scope.js +32 -0
- package/dist/wirings/secret/secret.types.d.ts +14 -0
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/variable/validate-variable-definitions.js +2 -0
- package/dist/wirings/variable/variable.types.d.ts +14 -0
- package/dist/wirings/workflow/dsl/index.d.ts +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +89 -7
- package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
- package/dist/wirings/workflow/graph/graph-runner.js +3 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
- package/dist/wirings/workflow/index.d.ts +2 -2
- package/dist/wirings/workflow/index.js +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +59 -1
- package/dist/wirings/workflow/pikku-workflow-service.js +187 -0
- package/dist/wirings/workflow/workflow.types.d.ts +1 -3
- package/package.json +6 -1
- package/run-tests.sh +1 -0
- package/src/dev/hot-reload.ts +13 -68
- package/src/dev/module-runner.test.ts +169 -0
- package/src/dev/module-runner.ts +103 -0
- package/src/dev/reload-meta.test.ts +31 -2
- package/src/dev/reload-meta.ts +26 -0
- package/src/errors/errors.ts +24 -0
- package/src/function/function-runner.test.ts +276 -142
- package/src/function/function-runner.ts +52 -145
- package/src/function/functions.types.ts +28 -38
- package/src/index.ts +8 -6
- package/src/middleware/index.ts +1 -5
- package/src/permissions.test.ts +160 -373
- package/src/permissions.ts +137 -279
- package/src/pikku-state.ts +0 -2
- package/src/scopes.test.ts +167 -0
- package/src/scopes.ts +69 -0
- package/src/services/ai-embedding-service.ts +31 -0
- package/src/services/credential-wire-service.ts +10 -4
- package/src/services/index.ts +20 -0
- package/src/services/meta-service.ts +68 -3
- package/src/services/queue-webhook-service.test.ts +408 -0
- package/src/services/queue-webhook-service.ts +182 -0
- package/src/services/scope-service.ts +75 -0
- package/src/services/typed-secret-service.test.ts +35 -0
- package/src/services/typed-secret-service.ts +39 -4
- package/src/services/webhook-service.ts +180 -0
- package/src/services/workflow-service.ts +7 -0
- package/src/testing/service-tests.ts +49 -0
- package/src/types/core.types.ts +42 -14
- package/src/types/state.types.ts +23 -3
- package/src/utils/hmac.ts +27 -0
- package/src/utils/safe-fetch.test.ts +373 -0
- package/src/utils/safe-fetch.ts +213 -0
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
- package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +483 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +324 -13
- package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +28 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +301 -10
- package/src/wirings/ai-agent/ai-agent-stream.ts +122 -16
- package/src/wirings/ai-agent/ai-agent.types.ts +57 -0
- package/src/wirings/ai-agent/index.ts +5 -0
- package/src/wirings/ai-agent/voice-input.test.ts +90 -0
- package/src/wirings/ai-agent/voice-input.ts +9 -40
- package/src/wirings/channel/channel-common.ts +0 -1
- package/src/wirings/channel/channel-handler.ts +1 -9
- package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
- package/src/wirings/channel/channel-middleware-runner.ts +39 -27
- package/src/wirings/channel/channel.types.ts +0 -6
- package/src/wirings/cli/cli-runner.ts +4 -2
- package/src/wirings/cli/cli.types.ts +0 -2
- package/src/wirings/cli/command-parser.test.ts +130 -0
- package/src/wirings/cli/command-parser.ts +80 -2
- package/src/wirings/credential/credential.types.ts +14 -0
- package/src/wirings/credential/validate-credential-definitions.ts +1 -0
- package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
- package/src/wirings/gateway/gateway-runner.ts +114 -68
- package/src/wirings/gateway/gateway.types.ts +7 -9
- package/src/wirings/http/http-routes.ts +0 -3
- package/src/wirings/http/http-runner.test.ts +1 -11
- package/src/wirings/http/http-runner.ts +16 -39
- package/src/wirings/http/http.types.ts +7 -14
- package/src/wirings/mcp/mcp-runner.ts +0 -2
- package/src/wirings/mcp/mcp.types.ts +3 -11
- package/src/wirings/oauth2/index.ts +0 -3
- package/src/wirings/rpc/addon-runner.ts +233 -0
- package/src/wirings/rpc/index.ts +17 -1
- package/src/wirings/rpc/remote-addon-auth.ts +69 -0
- package/src/wirings/rpc/rpc-runner.test.ts +319 -16
- package/src/wirings/rpc/rpc-runner.ts +155 -3
- package/src/wirings/rpc/rpc-types.ts +16 -5
- package/src/wirings/rpc/wire-addon.test.ts +6 -3
- package/src/wirings/rpc/wire-addon.ts +9 -0
- package/src/wirings/rpc/wire-remote-addon.ts +57 -0
- package/src/wirings/scope/index.ts +14 -0
- package/src/wirings/scope/scope.test.ts +135 -0
- package/src/wirings/scope/scope.types.ts +44 -0
- package/src/wirings/scope/validate-scope-definitions.ts +110 -0
- package/src/wirings/scope/wire-scope.ts +34 -0
- package/src/wirings/secret/secret.types.ts +14 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/variable/validate-variable-definitions.ts +2 -0
- package/src/wirings/variable/variable.types.ts +14 -0
- package/src/wirings/workflow/dsl/index.ts +4 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +97 -6
- package/src/wirings/workflow/graph/graph-node.ts +2 -0
- package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
- package/src/wirings/workflow/graph/graph-runner.ts +3 -0
- package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
- package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
- package/src/wirings/workflow/index.ts +5 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +272 -0
- package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
- package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
- package/src/wirings/workflow/workflow.types.ts +4 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
- package/src/wirings/oauth2/oauth2-client.ts +0 -331
- package/src/wirings/oauth2/oauth2-routes.ts +0 -234
|
@@ -142,6 +142,23 @@ addError(WorkflowRunCancelledError, {
|
|
|
142
142
|
status: 409,
|
|
143
143
|
message: 'Workflow was cancelled.',
|
|
144
144
|
});
|
|
145
|
+
/**
|
|
146
|
+
* A decision arrived for an approval gate that has already resolved. The gate
|
|
147
|
+
* caches its outcome as the step result and never re-reads run state, so the
|
|
148
|
+
* decision could not take effect — it is rejected rather than accepted and
|
|
149
|
+
* dropped.
|
|
150
|
+
*/
|
|
151
|
+
export class WorkflowApprovalResolvedError extends PikkuError {
|
|
152
|
+
payload;
|
|
153
|
+
constructor(reason, outcome) {
|
|
154
|
+
super(`Approval already ${outcome}: ${reason}`);
|
|
155
|
+
this.payload = { reason, outcome };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
addError(WorkflowApprovalResolvedError, {
|
|
159
|
+
status: 409,
|
|
160
|
+
message: 'Approval has already been resolved.',
|
|
161
|
+
});
|
|
145
162
|
export class WorkflowServiceNotInitialized extends Error {
|
|
146
163
|
}
|
|
147
164
|
export class WorkflowStepNameNotString extends Error {
|
|
@@ -1139,6 +1156,16 @@ export class PikkuWorkflowService {
|
|
|
1139
1156
|
}
|
|
1140
1157
|
}
|
|
1141
1158
|
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Run a failed step's compensation handler as a durable step of its own.
|
|
1161
|
+
*
|
|
1162
|
+
* Durable rather than a bare invoke so a replay does not compensate twice —
|
|
1163
|
+
* a handler is typically a refund or a rollback. `onError` is deliberately
|
|
1164
|
+
* not forwarded: a compensation handler cannot itself compensate.
|
|
1165
|
+
*/
|
|
1166
|
+
async runStepCompensation(runId, stepName, onErrorRpcName, rpcService, error) {
|
|
1167
|
+
await this.rpcStep(runId, `${stepName}:onError`, onErrorRpcName, { error: { message: error.message } }, rpcService, { retries: 0 });
|
|
1168
|
+
}
|
|
1142
1169
|
async rpcStep(runId, logicalStepName, rpcName, data, rpcService, stepOptions) {
|
|
1143
1170
|
// Capture the predecessor before nextStepKey advances the lineage to us.
|
|
1144
1171
|
const fromStepName = this.lastStepName(runId);
|
|
@@ -1151,6 +1178,7 @@ export class PikkuWorkflowService {
|
|
|
1151
1178
|
retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
|
|
1152
1179
|
retryDelay: stepOptions?.retryDelay,
|
|
1153
1180
|
actor: stepOptions?.actor,
|
|
1181
|
+
onError: stepOptions?.onError,
|
|
1154
1182
|
};
|
|
1155
1183
|
// Check if step already exists
|
|
1156
1184
|
let stepState;
|
|
@@ -1169,6 +1197,11 @@ export class PikkuWorkflowService {
|
|
|
1169
1197
|
// Step failed with retries exhausted - throw error to fail the workflow
|
|
1170
1198
|
const error = new Error(stepState.error?.message ||
|
|
1171
1199
|
`Step '${stepName}' failed after exhausting all retries`);
|
|
1200
|
+
// Compensation, mirroring a graph node's onError: run the handler, then
|
|
1201
|
+
// still throw — the workflow fails either way.
|
|
1202
|
+
if (resolvedStepOptions.onError) {
|
|
1203
|
+
await this.runStepCompensation(runId, stepName, resolvedStepOptions.onError, rpcService, error);
|
|
1204
|
+
}
|
|
1172
1205
|
// Preserve original error properties if available
|
|
1173
1206
|
if (stepState.error) {
|
|
1174
1207
|
Object.assign(error, stepState.error);
|
|
@@ -1381,6 +1414,156 @@ export class PikkuWorkflowService {
|
|
|
1381
1414
|
throw new WorkflowSuspendedException(runId, reason);
|
|
1382
1415
|
});
|
|
1383
1416
|
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Wake a run later by enqueuing a delayed orchestrator pass. Deliberately NOT
|
|
1419
|
+
* {@link scheduleSleep}: that resolves the step it is given, which for an
|
|
1420
|
+
* approval would resolve the gate itself. This only nudges the run to replay
|
|
1421
|
+
* and re-evaluate — the gate stays the sole judge of its own outcome.
|
|
1422
|
+
*
|
|
1423
|
+
* Best-effort by design. Expiry is decided from the recorded deadline on
|
|
1424
|
+
* replay, so losing this wake costs liveness (the run sits until something
|
|
1425
|
+
* else resumes it), never correctness.
|
|
1426
|
+
*/
|
|
1427
|
+
async scheduleRunWake(runId, delay) {
|
|
1428
|
+
try {
|
|
1429
|
+
const queueService = this.verifyQueueService();
|
|
1430
|
+
const run = await this.getRun(runId);
|
|
1431
|
+
if (!run?.workflow)
|
|
1432
|
+
return;
|
|
1433
|
+
await queueService.add(this.getOrchestratorQueueName(run.workflow), { runId }, { ...this.resolveStepJobOptions(), delay });
|
|
1434
|
+
}
|
|
1435
|
+
catch (error) {
|
|
1436
|
+
this.logger?.warn(`Failed to schedule approval expiry wake for run ${runId}; expiry will still resolve on the next replay`, error);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* Durable step name for an approval gate. Namespaced separately from suspend
|
|
1441
|
+
* so the two can't collide, and derived from `reason` for the same reason
|
|
1442
|
+
* {@link getSuspendStepName} is: it must be stable across replays.
|
|
1443
|
+
*/
|
|
1444
|
+
getApprovalStepName(reason) {
|
|
1445
|
+
return `__workflow_approval:${reason}`;
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Run-state key holding an approval gate's record. Hex-encoded because the
|
|
1449
|
+
* Mongo backend restricts state keys to `/^[a-zA-Z0-9_]+$/` and a `reason` is
|
|
1450
|
+
* arbitrary human text. One key per gate, so two gates resolving concurrently
|
|
1451
|
+
* can't clobber each other through a read-modify-write.
|
|
1452
|
+
*/
|
|
1453
|
+
approvalStateKey(stepName) {
|
|
1454
|
+
let hex = '';
|
|
1455
|
+
for (const byte of new TextEncoder().encode(stepName)) {
|
|
1456
|
+
hex += byte.toString(16).padStart(2, '0');
|
|
1457
|
+
}
|
|
1458
|
+
return `__approval_${hex}`;
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Record a decision against an approval gate and wake the run. Called from
|
|
1462
|
+
* outside the workflow (an HTTP route, an RPC), so the schema value is NOT in
|
|
1463
|
+
* scope here — the payload is stored raw and validated on replay inside the
|
|
1464
|
+
* workflow body, which is the only place the schema exists. An invalid payload
|
|
1465
|
+
* therefore leaves the gate closed rather than failing the run.
|
|
1466
|
+
*
|
|
1467
|
+
* `reason` addresses the first reach of that gate. An approval reached more
|
|
1468
|
+
* than once under the same reason (e.g. in a loop) gets `#N`-suffixed step
|
|
1469
|
+
* rows that this cannot currently target.
|
|
1470
|
+
*/
|
|
1471
|
+
async approveStep(runId, reason, decision) {
|
|
1472
|
+
const stepName = this.getApprovalStepName(reason);
|
|
1473
|
+
const stateKey = this.approvalStateKey(stepName);
|
|
1474
|
+
// A resolved gate returns its cached step result and never re-reads state,
|
|
1475
|
+
// so a decision recorded now would be silently discarded — most obviously
|
|
1476
|
+
// when it loses the race with expiry. Reject instead, so the approver
|
|
1477
|
+
// learns their decision did not land.
|
|
1478
|
+
let resolved;
|
|
1479
|
+
try {
|
|
1480
|
+
resolved = await this.getStepState(runId, stepName);
|
|
1481
|
+
}
|
|
1482
|
+
catch {
|
|
1483
|
+
// No step row yet: the run has not reached the gate. Recording a decision
|
|
1484
|
+
// ahead of it is legitimate — the gate picks it up on arrival.
|
|
1485
|
+
}
|
|
1486
|
+
if (resolved?.stepId && resolved.status === 'succeeded') {
|
|
1487
|
+
const outcome = resolved.result;
|
|
1488
|
+
throw new WorkflowApprovalResolvedError(reason, outcome?.status ?? 'decided');
|
|
1489
|
+
}
|
|
1490
|
+
const state = await this.getRunState(runId);
|
|
1491
|
+
const record = (state[stateKey] ?? {});
|
|
1492
|
+
await this.updateRunState(runId, stateKey, {
|
|
1493
|
+
...record,
|
|
1494
|
+
decision,
|
|
1495
|
+
decidedAt: new Date().toISOString(),
|
|
1496
|
+
error: undefined,
|
|
1497
|
+
});
|
|
1498
|
+
await this.resumeWorkflow(runId);
|
|
1499
|
+
}
|
|
1500
|
+
async approvalStep(runId, reason, options) {
|
|
1501
|
+
const fromStepName = this.lastStepName(runId);
|
|
1502
|
+
const approvalStepName = this.nextStepKey(runId, this.getApprovalStepName(reason));
|
|
1503
|
+
return await this.withStepLock(runId, approvalStepName, async () => {
|
|
1504
|
+
let stepState;
|
|
1505
|
+
try {
|
|
1506
|
+
stepState = await this.getStepState(runId, approvalStepName);
|
|
1507
|
+
}
|
|
1508
|
+
catch {
|
|
1509
|
+
stepState = await this.insertStepState(runId, approvalStepName, 'pikkuWorkflowApproval', { reason, expiry: options.expiry }, undefined, fromStepName);
|
|
1510
|
+
}
|
|
1511
|
+
if (!stepState.stepId) {
|
|
1512
|
+
stepState = await this.insertStepState(runId, approvalStepName, 'pikkuWorkflowApproval', { reason, expiry: options.expiry }, undefined, fromStepName);
|
|
1513
|
+
}
|
|
1514
|
+
// Unlike suspend, `succeeded` here means a decision (or expiry) was
|
|
1515
|
+
// actually resolved, and the step result IS the return channel.
|
|
1516
|
+
if (stepState.status === 'succeeded') {
|
|
1517
|
+
return stepState.result;
|
|
1518
|
+
}
|
|
1519
|
+
const stateKey = this.approvalStateKey(approvalStepName);
|
|
1520
|
+
let record = ((await this.getRunState(runId))[stateKey] ?? {});
|
|
1521
|
+
if (stepState.status === 'pending') {
|
|
1522
|
+
await this.setStepRunning(stepState.stepId);
|
|
1523
|
+
// First reach: stamp the deadline and nudge the run awake when it
|
|
1524
|
+
// passes. The deadline is what's authoritative — see below.
|
|
1525
|
+
if (options.expiry !== undefined && !record.expiresAt) {
|
|
1526
|
+
const expiresAt = new Date(Date.now() + getDurationInMilliseconds(options.expiry)).toISOString();
|
|
1527
|
+
record = { ...record, expiresAt };
|
|
1528
|
+
await this.updateRunState(runId, stateKey, record);
|
|
1529
|
+
await this.scheduleRunWake(runId, getDurationInMilliseconds(options.expiry));
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
if (record.decision !== undefined) {
|
|
1533
|
+
const validation = await options.schema['~standard'].validate(record.decision);
|
|
1534
|
+
if (validation.issues) {
|
|
1535
|
+
// Drop the bad decision and re-close the gate, leaving the failure
|
|
1536
|
+
// legible to whoever tries next. Failing the run instead would let any
|
|
1537
|
+
// caller kill a workflow with a malformed payload.
|
|
1538
|
+
await this.updateRunState(runId, stateKey, {
|
|
1539
|
+
...record,
|
|
1540
|
+
decision: undefined,
|
|
1541
|
+
decidedAt: undefined,
|
|
1542
|
+
error: validation.issues.map((issue) => ({
|
|
1543
|
+
message: issue.message,
|
|
1544
|
+
path: issue.path?.map((segment) => typeof segment === 'object' ? segment.key : segment),
|
|
1545
|
+
})),
|
|
1546
|
+
});
|
|
1547
|
+
throw new WorkflowSuspendedException(runId, reason);
|
|
1548
|
+
}
|
|
1549
|
+
const outcome = {
|
|
1550
|
+
status: 'decided',
|
|
1551
|
+
data: validation.value,
|
|
1552
|
+
};
|
|
1553
|
+
await this.setStepResult(stepState.stepId, outcome);
|
|
1554
|
+
return outcome;
|
|
1555
|
+
}
|
|
1556
|
+
// Expiry is decided by comparing against the recorded deadline rather than
|
|
1557
|
+
// by the timer having fired, so a duplicate, late, or dropped timer all
|
|
1558
|
+
// produce the same answer.
|
|
1559
|
+
if (record.expiresAt && Date.now() >= Date.parse(record.expiresAt)) {
|
|
1560
|
+
const outcome = { status: 'expired' };
|
|
1561
|
+
await this.setStepResult(stepState.stepId, outcome);
|
|
1562
|
+
return outcome;
|
|
1563
|
+
}
|
|
1564
|
+
throw new WorkflowSuspendedException(runId, reason);
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1384
1567
|
createWorkflowWire(name, runId, rpcService, addonNamespace) {
|
|
1385
1568
|
const workflowWire = {
|
|
1386
1569
|
name,
|
|
@@ -1492,6 +1675,10 @@ export class PikkuWorkflowService {
|
|
|
1492
1675
|
this.verifyStepName(reason);
|
|
1493
1676
|
await this.suspendStep(runId, reason);
|
|
1494
1677
|
},
|
|
1678
|
+
approval: (async (reason, options) => {
|
|
1679
|
+
this.verifyStepName(reason);
|
|
1680
|
+
return await this.approvalStep(runId, reason, options);
|
|
1681
|
+
}),
|
|
1495
1682
|
runScheduledTask: async (taskName) => {
|
|
1496
1683
|
await runScheduledTask({ name: taskName });
|
|
1497
1684
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { SerializedError, CommonWireMeta } from '../../types/core.types.js';
|
|
2
2
|
import type { CorePikkuFunctionConfig } from '../../function/functions.types.js';
|
|
3
3
|
export type { WorkflowService } from '../../services/workflow-service.js';
|
|
4
|
-
export type { WorkflowStepOptions, WorkflowExpectEventuallyOptions, WorkflowExpectErrorOptions, WorkflowExpectServiceOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, PikkuScenarioWire, } from './dsl/workflow-dsl.types.js';
|
|
4
|
+
export type { WorkflowStepOptions, WorkflowExpectEventuallyOptions, WorkflowExpectErrorOptions, WorkflowExpectServiceOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, WorkflowWireApproval, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, PikkuScenarioWire, } from './dsl/workflow-dsl.types.js';
|
|
5
5
|
import type { WorkflowStepMeta } from './dsl/workflow-dsl.types.js';
|
|
6
6
|
export interface WorkflowRunWire {
|
|
7
7
|
type: string;
|
|
@@ -194,8 +194,6 @@ export type CoreWorkflow<PikkuFunctionConfig extends CorePikkuFunctionConfig<any
|
|
|
194
194
|
func: PikkuFunctionConfig;
|
|
195
195
|
/** Middleware chain for this workflow */
|
|
196
196
|
middleware?: PikkuFunctionConfig['middleware'];
|
|
197
|
-
/** Permission requirements */
|
|
198
|
-
permissions?: PikkuFunctionConfig['permissions'];
|
|
199
197
|
/** Tags for organization and filtering */
|
|
200
198
|
tags?: string[];
|
|
201
199
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/core",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.66",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"./scheduler": "./dist/wirings/scheduler/index.js",
|
|
34
34
|
"./trigger": "./dist/wirings/trigger/index.js",
|
|
35
35
|
"./rpc": "./dist/wirings/rpc/index.js",
|
|
36
|
+
"./safe-fetch": "./dist/utils/safe-fetch.js",
|
|
36
37
|
"./mcp": "./dist/wirings/mcp/index.js",
|
|
37
38
|
"./ai-agent": "./dist/wirings/ai-agent/index.js",
|
|
38
39
|
"./gateway": "./dist/wirings/gateway/index.js",
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
"./cli/channel": "./dist/wirings/cli/channel/index.js",
|
|
42
43
|
"./node": "./dist/wirings/node/index.js",
|
|
43
44
|
"./credential": "./dist/wirings/credential/index.js",
|
|
45
|
+
"./scope": "./dist/wirings/scope/index.js",
|
|
44
46
|
"./secret": "./dist/wirings/secret/index.js",
|
|
45
47
|
"./variable": "./dist/wirings/variable/index.js",
|
|
46
48
|
"./oauth2": "./dist/wirings/oauth2/index.js",
|
|
@@ -53,6 +55,7 @@
|
|
|
53
55
|
"./services/temporary-file-service": "./dist/services/temporary-file-service.js",
|
|
54
56
|
"./services/gopass-secrets": "./dist/services/gopass-secrets.js",
|
|
55
57
|
"./crypto-utils": "./dist/crypto-utils.js",
|
|
58
|
+
"./hmac": "./dist/utils/hmac.js",
|
|
56
59
|
"./internal": "./dist/internal.js",
|
|
57
60
|
"./schema": "./dist/schema.js",
|
|
58
61
|
"./testing": "./dist/testing/index.js",
|
|
@@ -67,7 +70,9 @@
|
|
|
67
70
|
"picoquery": "^2.5.0"
|
|
68
71
|
},
|
|
69
72
|
"devDependencies": {
|
|
73
|
+
"@ag-ui/core": "0.0.57",
|
|
70
74
|
"@types/node": "^24.11.0",
|
|
75
|
+
"esbuild": "~0.27.0",
|
|
71
76
|
"tsx": "^4.21.0",
|
|
72
77
|
"typescript": "^6.0.3"
|
|
73
78
|
},
|
package/run-tests.sh
CHANGED
|
@@ -53,6 +53,7 @@ fi
|
|
|
53
53
|
|
|
54
54
|
if [ "$coverage_mode" = true ]; then
|
|
55
55
|
node_cmd+=(--test-coverage-include="src/**/*.{ts,js}" --test-coverage-exclude="**/dist/**" --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info)
|
|
56
|
+
export PIKKU_TEST_COVERAGE=1
|
|
56
57
|
fi
|
|
57
58
|
|
|
58
59
|
# Execute the node command with the expanded list of files
|
package/src/dev/hot-reload.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { watch, type FSWatcher } from 'node:fs'
|
|
2
|
-
import { stat
|
|
3
|
-
import { basename,
|
|
4
|
-
import { pathToFileURL } from 'node:url'
|
|
5
|
-
|
|
6
|
-
import { register } from 'tsx/esm/api'
|
|
2
|
+
import { stat } from 'node:fs/promises'
|
|
3
|
+
import { basename, join, resolve, relative } from 'node:path'
|
|
7
4
|
|
|
8
5
|
import { pikkuState } from '../pikku-state.js'
|
|
9
6
|
import { clearMiddlewareCache } from '../middleware-runner.js'
|
|
@@ -12,6 +9,7 @@ import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middlewa
|
|
|
12
9
|
import { httpRouter } from '../wirings/http/routers/http-router.js'
|
|
13
10
|
import type { Logger } from '../services/logger.js'
|
|
14
11
|
import type { CorePikkuFunctionConfig } from '../function/functions.types.js'
|
|
12
|
+
import { createModuleRunner } from './module-runner.js'
|
|
15
13
|
|
|
16
14
|
export * from './reload-meta.js'
|
|
17
15
|
|
|
@@ -54,67 +52,19 @@ const findCompiledFile = async (
|
|
|
54
52
|
return null
|
|
55
53
|
}
|
|
56
54
|
|
|
57
|
-
//
|
|
58
|
-
// (
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (tsxRegistered) return
|
|
64
|
-
register()
|
|
65
|
-
tsxRegistered = true
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
let tempCounter = 0
|
|
69
|
-
|
|
70
|
-
const reimportModule = async (
|
|
71
|
-
filePath: string,
|
|
72
|
-
useTsx = false
|
|
73
|
-
): Promise<Record<string, unknown> | null> => {
|
|
74
|
-
try {
|
|
75
|
-
if (useTsx) {
|
|
76
|
-
// Import a uniquely-named sibling copy: a `?t=` query does NOT bust
|
|
77
|
-
// the cache on either runtime (Bun keys module identity on the bare
|
|
78
|
-
// path; tsx's transform cache keys on the file path too), so a fresh
|
|
79
|
-
// path is the only reliable re-import. Same directory → identical
|
|
80
|
-
// resolution for relative and package-`imports` (#…) specifiers. The
|
|
81
|
-
// dot-prefix keeps it out of the watcher.
|
|
82
|
-
if (!process.versions.bun) {
|
|
83
|
-
// Node needs tsx's loader to import raw .ts; Bun imports it natively.
|
|
84
|
-
ensureTsxRegistered()
|
|
85
|
-
}
|
|
86
|
-
const abs = resolve(filePath)
|
|
87
|
-
const tempPath = join(
|
|
88
|
-
dirname(abs),
|
|
89
|
-
`.pikku-hot-${++tempCounter}-${basename(abs)}`
|
|
90
|
-
)
|
|
91
|
-
await copyFile(abs, tempPath)
|
|
92
|
-
try {
|
|
93
|
-
return await import(pathToFileURL(tempPath).href)
|
|
94
|
-
} finally {
|
|
95
|
-
await rm(tempPath, { force: true }).catch(() => {
|
|
96
|
-
// Best-effort temp cleanup; a leftover dotfile is watcher-ignored.
|
|
97
|
-
})
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const content = await readFile(resolve(filePath), 'utf-8')
|
|
102
|
-
const dataUrl =
|
|
103
|
-
'data:text/javascript;base64,' + Buffer.from(content).toString('base64')
|
|
104
|
-
return await import(dataUrl)
|
|
105
|
-
} catch {
|
|
106
|
-
return null
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
55
|
+
// Changed user files are re-run through an evictable module runner keyed by a
|
|
56
|
+
// stable path (createModuleRunner), NOT re-imported under a fresh URL. A
|
|
57
|
+
// fresh-URL import (a `data:` URL on Node, a uniquely-named temp sibling on
|
|
58
|
+
// Bun) permanently leaks a record in the native ESM loader map on every
|
|
59
|
+
// reload, OOMing long editing sessions; the runner overwrites a single slot so
|
|
60
|
+
// the previous module is collected. See ./module-runner.ts.
|
|
110
61
|
const isWatchedTsFile = (filename: string): boolean => {
|
|
111
62
|
return (
|
|
112
63
|
filename.endsWith('.ts') &&
|
|
113
64
|
!filename.endsWith('.test.ts') &&
|
|
114
65
|
!filename.endsWith('.d.ts') &&
|
|
115
66
|
!filename.endsWith('.gen.ts') &&
|
|
116
|
-
// Hidden files: editor/sed atomic-write temps
|
|
117
|
-
// sibling copies must never trigger a reload of themselves.
|
|
67
|
+
// Hidden files: editor/sed atomic-write temps must never trigger a reload.
|
|
118
68
|
!basename(filename).startsWith('.')
|
|
119
69
|
)
|
|
120
70
|
}
|
|
@@ -135,6 +85,7 @@ export async function pikkuDevReloader(
|
|
|
135
85
|
const watchers: FSWatcher[] = []
|
|
136
86
|
|
|
137
87
|
const functionsMap = pikkuState(null, 'function', 'functions')
|
|
88
|
+
const moduleRunner = createModuleRunner()
|
|
138
89
|
|
|
139
90
|
const handleFileChange = async (changedTsFile: string) => {
|
|
140
91
|
const start = Date.now()
|
|
@@ -150,15 +101,8 @@ export async function pikkuDevReloader(
|
|
|
150
101
|
absPikkuDir
|
|
151
102
|
)
|
|
152
103
|
const importPath = compiledFile ?? changedTsFile
|
|
153
|
-
const usedTsxFallback = !compiledFile
|
|
154
|
-
|
|
155
|
-
if (usedTsxFallback) {
|
|
156
|
-
logger.debug(
|
|
157
|
-
`Hot-reload using tsx fallback for: ${relative(process.cwd(), changedTsFile)}`
|
|
158
|
-
)
|
|
159
|
-
}
|
|
160
104
|
|
|
161
|
-
const mod = await
|
|
105
|
+
const mod = await moduleRunner.run(importPath)
|
|
162
106
|
if (!mod) {
|
|
163
107
|
logger.error(
|
|
164
108
|
`Failed to import: ${relative(process.cwd(), importPath)} (keeping old code)`
|
|
@@ -260,6 +204,7 @@ export async function pikkuDevReloader(
|
|
|
260
204
|
for (const watcher of watchers) {
|
|
261
205
|
watcher.close()
|
|
262
206
|
}
|
|
207
|
+
moduleRunner.clear()
|
|
263
208
|
},
|
|
264
209
|
// Drain the queue of recently re-imported files and import them again.
|
|
265
210
|
// A dev-server watcher calls this AFTER its codegen pass has refreshed
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, test, beforeEach, afterEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import v8 from 'node:v8'
|
|
4
|
+
import vm from 'node:vm'
|
|
5
|
+
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
|
|
6
|
+
import { createRequire } from 'node:module'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { pathToFileURL } from 'node:url'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
|
|
11
|
+
import { createModuleRunner } from './module-runner.js'
|
|
12
|
+
|
|
13
|
+
// A forced-GC hook without launching the process with a flag: on Bun use the
|
|
14
|
+
// native collector; on Node flip --expose-gc on just long enough to grab `gc`.
|
|
15
|
+
const getGc = (): (() => void) => {
|
|
16
|
+
const bun = (globalThis as any).Bun
|
|
17
|
+
if (bun) return () => bun.gc(true)
|
|
18
|
+
v8.setFlagsFromString('--expose-gc')
|
|
19
|
+
const gc = vm.runInNewContext('gc') as () => void
|
|
20
|
+
v8.setFlagsFromString('--no-expose-gc')
|
|
21
|
+
return () => gc()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const heapUsedMb = () => process.memoryUsage().heapUsed / 1048576
|
|
25
|
+
|
|
26
|
+
// V8's --experimental-test-coverage retains coverage data for every compiled
|
|
27
|
+
// script for the life of the run, which pins the runner's per-reload compiled
|
|
28
|
+
// functions (and would equally pin the old leaky mechanism), so a heap-growth
|
|
29
|
+
// measurement can't discriminate a leak from a non-leak while it's on. run-tests.sh
|
|
30
|
+
// exports this marker in --coverage mode (the flag itself is not visible to the
|
|
31
|
+
// isolated test child). The single-slot registry guarantee below still runs
|
|
32
|
+
// unconditionally.
|
|
33
|
+
const UNDER_COVERAGE = process.env.PIKKU_TEST_COVERAGE === '1'
|
|
34
|
+
|
|
35
|
+
describe('createModuleRunner', { concurrency: false }, () => {
|
|
36
|
+
let tmpDir: string
|
|
37
|
+
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'pikku-module-runner-'))
|
|
40
|
+
await writeFile(
|
|
41
|
+
join(tmpDir, 'package.json'),
|
|
42
|
+
JSON.stringify({ type: 'module' })
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterEach(async () => {
|
|
47
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('runs a TS module and returns its exports', async () => {
|
|
51
|
+
const runner = createModuleRunner()
|
|
52
|
+
const file = join(tmpDir, 'todo.ts')
|
|
53
|
+
await writeFile(
|
|
54
|
+
file,
|
|
55
|
+
`interface Todo { id: string }
|
|
56
|
+
export const createTodo = { func: async (_s: any, d: Todo) => ({ id: d.id }) }`
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const mod = await runner.run(file)
|
|
60
|
+
assert.ok(mod)
|
|
61
|
+
const createTodo = mod!.createTodo as {
|
|
62
|
+
func: (...a: any[]) => Promise<any>
|
|
63
|
+
}
|
|
64
|
+
assert.equal(typeof createTodo.func, 'function')
|
|
65
|
+
assert.deepEqual(await createTodo.func({}, { id: 'abc' }), { id: 'abc' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('top-level import side effects hit the live singleton dependency', async () => {
|
|
69
|
+
// Delegated imports must resolve to the SAME module instance the rest of
|
|
70
|
+
// the process holds, so a user file's wire* side effect mutates live state.
|
|
71
|
+
await writeFile(
|
|
72
|
+
join(tmpDir, 'registry.js'),
|
|
73
|
+
`export const registry = new Map()
|
|
74
|
+
export const wire = (name, cfg) => registry.set(name, cfg)`
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const runner = createModuleRunner()
|
|
78
|
+
const userFile = join(tmpDir, 'wired.ts')
|
|
79
|
+
await writeFile(
|
|
80
|
+
userFile,
|
|
81
|
+
`import { wire } from './registry.js'
|
|
82
|
+
export const createTodo = { func: async () => ({ ok: true }) }
|
|
83
|
+
wire('createTodo', createTodo)`
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const mod = await runner.run(userFile)
|
|
87
|
+
assert.ok(mod)
|
|
88
|
+
|
|
89
|
+
// Read the dependency through the same resolver the runner uses, so we
|
|
90
|
+
// observe the exact instance the user module's `import` bound to (using a
|
|
91
|
+
// separate `import()` would resolve a distinct copy under the tsx test
|
|
92
|
+
// loader and prove nothing about live-singleton delegation).
|
|
93
|
+
const dep = createRequire(pathToFileURL(userFile))('./registry.js') as {
|
|
94
|
+
registry: Map<string, unknown>
|
|
95
|
+
}
|
|
96
|
+
assert.equal(dep.registry.has('createTodo'), true)
|
|
97
|
+
assert.strictEqual(dep.registry.get('createTodo'), mod!.createTodo)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('re-running the same path overwrites a single registry slot', async () => {
|
|
101
|
+
const runner = createModuleRunner()
|
|
102
|
+
const file = join(tmpDir, 'value.ts')
|
|
103
|
+
|
|
104
|
+
await writeFile(file, `export const value = { func: async () => 'v1' }`)
|
|
105
|
+
const first = await runner.run(file)
|
|
106
|
+
assert.equal(await (first!.value as any).func(), 'v1')
|
|
107
|
+
|
|
108
|
+
await writeFile(file, `export const value = { func: async () => 'v2' }`)
|
|
109
|
+
const second = await runner.run(file)
|
|
110
|
+
assert.equal(await (second!.value as any).func(), 'v2')
|
|
111
|
+
|
|
112
|
+
// Stable key: many reloads of one path never grow the registry.
|
|
113
|
+
for (let i = 0; i < 20; i++) await runner.run(file)
|
|
114
|
+
assert.equal(runner.size, 1)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('returns null on a bad edit so the caller keeps old code', async () => {
|
|
118
|
+
const runner = createModuleRunner()
|
|
119
|
+
const file = join(tmpDir, 'broken.ts')
|
|
120
|
+
await writeFile(
|
|
121
|
+
file,
|
|
122
|
+
`export const oops = { func: async () => ( } ] syntax`
|
|
123
|
+
)
|
|
124
|
+
const mod = await runner.run(file)
|
|
125
|
+
assert.equal(mod, null)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('editing and reimporting a module 200x does not leak memory', async () => {
|
|
129
|
+
const gc = getGc()
|
|
130
|
+
const runner = createModuleRunner()
|
|
131
|
+
const file = join(tmpDir, 'big.ts')
|
|
132
|
+
// A sizeable module edited on every reload (the real dev pattern). The old
|
|
133
|
+
// fresh-URL reimport left one of these in the native ESM loader map per
|
|
134
|
+
// edit (~0.3-1.3 MB each) — ~84 MB (Node) / ~222 MB (Bun) over 200 edits —
|
|
135
|
+
// and OOMed. The evictable runner overwrites a single stable slot, so the
|
|
136
|
+
// previous module is collected and heap stays bounded.
|
|
137
|
+
const payload = JSON.stringify(
|
|
138
|
+
Array.from({ length: 2000 }, (_, i) => ({ i, s: 'field_' + i }))
|
|
139
|
+
)
|
|
140
|
+
const write = (marker: number) =>
|
|
141
|
+
writeFile(
|
|
142
|
+
file,
|
|
143
|
+
`export const data = ${payload}
|
|
144
|
+
export const handler = { func: async () => data.length }
|
|
145
|
+
// edit ${marker}`
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
// Warm up (first compile allocates esbuild + shared caches), then baseline.
|
|
149
|
+
await write(0)
|
|
150
|
+
await runner.run(file)
|
|
151
|
+
gc()
|
|
152
|
+
const baseline = heapUsedMb()
|
|
153
|
+
|
|
154
|
+
for (let i = 1; i <= 200; i++) {
|
|
155
|
+
await write(i)
|
|
156
|
+
const mod = await runner.run(file)
|
|
157
|
+
assert.ok(mod)
|
|
158
|
+
}
|
|
159
|
+
gc()
|
|
160
|
+
const growth = heapUsedMb() - baseline
|
|
161
|
+
|
|
162
|
+
assert.equal(runner.size, 1)
|
|
163
|
+
if (UNDER_COVERAGE) return
|
|
164
|
+
assert.ok(
|
|
165
|
+
growth < 15,
|
|
166
|
+
`heap grew ${growth.toFixed(1)} MB over 200 edits (expected < 15 MB)`
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
})
|