@pikku/core 0.12.94 → 0.12.96
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 +179 -0
- package/dist/dev/hot-reload.js +24 -4
- package/dist/dev/module-runner.d.ts +20 -3
- package/dist/dev/module-runner.js +17 -4
- package/dist/services/email-template.d.ts +43 -0
- package/dist/services/email-template.js +139 -0
- package/dist/services/http-personas.d.ts +6 -1
- package/dist/services/http-personas.js +4 -1
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/wirings/agent/agent-prepare.d.ts +14 -0
- package/dist/wirings/agent/agent-prepare.js +24 -0
- package/dist/wirings/agent/index.d.ts +1 -1
- package/dist/wirings/agent/index.js +1 -1
- package/dist/wirings/scheduler/scheduler-runner.js +0 -1
- package/dist/wirings/virtual-user/index.d.ts +1 -0
- package/dist/wirings/virtual-user/index.js +1 -0
- package/dist/wirings/virtual-user/virtual-user-derive.js +9 -0
- package/dist/wirings/virtual-user/virtual-user-scaffold.d.ts +267 -0
- package/dist/wirings/virtual-user/virtual-user-scaffold.js +400 -0
- package/dist/wirings/workflow/index.d.ts +1 -0
- package/dist/wirings/workflow/index.js +1 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +3 -9
- package/dist/wirings/workflow/scenario-prose.d.ts +23 -1
- package/dist/wirings/workflow/scenario-prose.js +12 -3
- package/dist/wirings/workflow/scenario-run.types.d.ts +7 -0
- package/dist/wirings/workflow/workflow-queue-routing.d.ts +18 -0
- package/dist/wirings/workflow/workflow-queue-routing.js +35 -0
- package/dist/wirings/workflow/workflow-status-stream.d.ts +28 -0
- package/dist/wirings/workflow/workflow-status-stream.js +105 -0
- package/package.json +1 -1
- package/src/dev/hot-reload.test.ts +42 -0
- package/src/dev/hot-reload.ts +30 -4
- package/src/dev/module-runner.test.ts +56 -13
- package/src/dev/module-runner.ts +32 -10
- package/src/public-surface.json +17 -1
- package/src/services/email-template.test.ts +311 -0
- package/src/services/email-template.ts +254 -0
- package/src/services/http-personas.ts +10 -2
- package/src/services/index.ts +8 -0
- package/src/services/persona-sign-in.test.ts +22 -0
- package/src/wirings/agent/agent-helpers.test.ts +63 -0
- package/src/wirings/agent/agent-prepare.ts +25 -0
- package/src/wirings/agent/index.ts +1 -0
- package/src/wirings/scheduler/scheduler-runner.test.ts +178 -0
- package/src/wirings/scheduler/scheduler-runner.ts +0 -1
- package/src/wirings/virtual-user/index.ts +20 -0
- package/src/wirings/virtual-user/virtual-user-derive.test.ts +33 -5
- package/src/wirings/virtual-user/virtual-user-derive.ts +9 -0
- package/src/wirings/virtual-user/virtual-user-scaffold.test.ts +795 -0
- package/src/wirings/virtual-user/virtual-user-scaffold.ts +634 -0
- package/src/wirings/workflow/index.ts +4 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +71 -2
- package/src/wirings/workflow/pikku-workflow-service.ts +5 -11
- package/src/wirings/workflow/scenario-prose.test.ts +134 -9
- package/src/wirings/workflow/scenario-prose.ts +37 -2
- package/src/wirings/workflow/scenario-run.types.ts +7 -0
- package/src/wirings/workflow/workflow-child-run-session.test.ts +79 -0
- package/src/wirings/workflow/workflow-queue-routing.ts +44 -0
- package/src/wirings/workflow/workflow-status-stream.test.ts +354 -0
- package/src/wirings/workflow/workflow-status-stream.ts +144 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -11,7 +11,7 @@ import { buildRunTimeline, reconstructStateAt, } from './run-timeline.js';
|
|
|
11
11
|
import { DEFAULT_STEP_RETRIES, WORKFLOW_CHILD_POLL_MAX_MS, WORKFLOW_END_STATES, WORKFLOW_POLL_FACTOR, WORKFLOW_POLL_MIN_MS, WORKFLOW_TERMINAL_STATES, isRunSettled, } from './workflow-constants.js';
|
|
12
12
|
import { WorkflowAsyncException, WorkflowCancelledException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotFoundError, WorkflowStepNameNotString, WorkflowSuspendedException, } from './workflow-errors.js';
|
|
13
13
|
import { resolveWorkflowMeta } from './workflow-meta-resolver.js';
|
|
14
|
-
import { jobGroupFor, orchestratorQueueName, resolveWorkflowConfig, stepJobOptions, stepWorkerQueueName, } from './workflow-queue-routing.js';
|
|
14
|
+
import { jobGroupFor, orchestratorQueueName, resolveWorkflowConfig, stepDispatchTarget, stepJobOptions, stepWorkerQueueName, } from './workflow-queue-routing.js';
|
|
15
15
|
import { wireWorkflowQueueWorkers } from './workflow-queue-wiring.js';
|
|
16
16
|
import { approvalStepNameFor, evaluateApprovalStep, recordApprovalDecision, } from './workflow-approval.js';
|
|
17
17
|
import { auditApprovalDecision } from './workflow-approval-audit.js';
|
|
@@ -323,16 +323,10 @@ export class PikkuWorkflowService {
|
|
|
323
323
|
});
|
|
324
324
|
}
|
|
325
325
|
async dispatchStep(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
const rpcMeta = typeof rpcFuncId === 'string' ? functionsMeta[rpcFuncId] : undefined;
|
|
329
|
-
const forceQueue = rpcMeta?.workflowQueued === true;
|
|
330
|
-
if (!forceQueue) {
|
|
326
|
+
const target = await stepDispatchTarget(rpcName, stepName, () => this.isInline(runId));
|
|
327
|
+
if (target === 'inline') {
|
|
331
328
|
return false;
|
|
332
329
|
}
|
|
333
|
-
if (!getSingletonServices()?.queueService) {
|
|
334
|
-
throw new Error(`Workflow step '${stepName}' (function '${rpcName}') is marked 'workflowQueued: true' but no queue service is configured.`);
|
|
335
|
-
}
|
|
336
330
|
try {
|
|
337
331
|
await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), { runId, stepName, rpcName, data, fromStepName }, {
|
|
338
332
|
...this.resolveStepJobOptions(stepOptions),
|
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
import type { ScenarioStepPhase } from './scenario-step.types.js';
|
|
2
2
|
export declare const renderStepTemplate: (template: string, input: unknown) => string;
|
|
3
|
-
export declare const composeStepProse: ({ phase, description, template, input, actor, keywordWidth, }: {
|
|
3
|
+
export declare const composeStepProse: ({ phase, description, template, input, actor, actorRole, continuesPhase, continuesActor, keywordWidth, }: {
|
|
4
4
|
phase: ScenarioStepPhase;
|
|
5
5
|
description: string;
|
|
6
6
|
template?: string;
|
|
7
7
|
input?: unknown;
|
|
8
8
|
actor?: string;
|
|
9
|
+
/**
|
|
10
|
+
* What this actor is, rendered as an apposition after their key — "yasser
|
|
11
|
+
* (the founder)". Only pass it where the actor has not been named yet: an
|
|
12
|
+
* ordinary run repeats one actor for a dozen steps, and repeating the role
|
|
13
|
+
* with them turns the one piece of context into the noise around it.
|
|
14
|
+
*/
|
|
15
|
+
actorRole?: string;
|
|
16
|
+
/**
|
|
17
|
+
* This step repeats the phase of the one before it, so it reads as `And`
|
|
18
|
+
* rather than saying `Given` three times — the same thing Gherkin does.
|
|
19
|
+
*/
|
|
20
|
+
continuesPhase?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The step before this one had the same actor. Combined with `continuesPhase`
|
|
23
|
+
* the subject is dropped, because English drops a repeated subject in a
|
|
24
|
+
* compound predicate: "yasser opens the dashboard / and sees the audit log".
|
|
25
|
+
*
|
|
26
|
+
* It takes both. Dropping the subject across a phase change gives "When opens
|
|
27
|
+
* the dashboard", and a pronoun instead of a name would give "they sees",
|
|
28
|
+
* since step templates are authored in the third person singular.
|
|
29
|
+
*/
|
|
30
|
+
continuesActor?: boolean;
|
|
9
31
|
keywordWidth?: number;
|
|
10
32
|
}) => string;
|
|
@@ -14,9 +14,13 @@ const formatValue = (value) => {
|
|
|
14
14
|
}
|
|
15
15
|
return String(value);
|
|
16
16
|
};
|
|
17
|
-
export const composeStepProse = ({ phase, description, template, input, actor, keywordWidth, }) => {
|
|
18
|
-
const keyword = capitalise(phase);
|
|
19
|
-
|
|
17
|
+
export const composeStepProse = ({ phase, description, template, input, actor, actorRole, continuesPhase, continuesActor, keywordWidth, }) => {
|
|
18
|
+
const keyword = capitalise(continuesPhase ? 'and' : phase);
|
|
19
|
+
// The actor key is the subject verbatim, with no article in front of it.
|
|
20
|
+
// "the ${actor}" only reads as English when the key happens to be a role
|
|
21
|
+
// noun — it turns a persona named after a person into "the nadia", which
|
|
22
|
+
// is the reporter quietly imposing a naming convention on the author.
|
|
23
|
+
const subject = continuesPhase && continuesActor ? '' : composeSubject(actor, actorRole);
|
|
20
24
|
const rendered = template ? renderStepTemplate(template, input) : description;
|
|
21
25
|
const sentence = [subject, rendered].filter(Boolean).join(' ');
|
|
22
26
|
if (keywordWidth === undefined) {
|
|
@@ -24,4 +28,9 @@ export const composeStepProse = ({ phase, description, template, input, actor, k
|
|
|
24
28
|
}
|
|
25
29
|
return `${keyword.padEnd(keywordWidth)} ${sentence}`;
|
|
26
30
|
};
|
|
31
|
+
const composeSubject = (actor, actorRole) => {
|
|
32
|
+
if (!actor)
|
|
33
|
+
return '';
|
|
34
|
+
return actorRole ? `${actor} (the ${actorRole})` : actor;
|
|
35
|
+
};
|
|
27
36
|
const capitalise = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
|
@@ -35,6 +35,13 @@ export interface ScenarioArtifact {
|
|
|
35
35
|
/** One step of a run, already joined to the prose that declared it. */
|
|
36
36
|
export interface ScenarioStepRow {
|
|
37
37
|
sentence: string;
|
|
38
|
+
/**
|
|
39
|
+
* The same sentence with the actor's role in it — "yasser (the founder)
|
|
40
|
+
* signs in". Set only on the step that first names each actor, and only
|
|
41
|
+
* when a persona declares a job title or a role, so a reader who wants the
|
|
42
|
+
* context picks this and one who wants the bare run picks `sentence`.
|
|
43
|
+
*/
|
|
44
|
+
sentenceWithRole?: string;
|
|
38
45
|
status: string;
|
|
39
46
|
durationMs?: number;
|
|
40
47
|
error?: string;
|
|
@@ -4,5 +4,23 @@ export type WorkflowQueueStrategy = 'per-workflow' | 'shared-groups';
|
|
|
4
4
|
export declare const resolveWorkflowConfig: () => WorkflowServiceConfig;
|
|
5
5
|
export declare const orchestratorQueueName: (strategy: WorkflowQueueStrategy, workflowName?: string) => string;
|
|
6
6
|
export declare const stepWorkerQueueName: (strategy: WorkflowQueueStrategy, rpcName?: string) => string;
|
|
7
|
+
/**
|
|
8
|
+
* How a step reaches its worker: on the queue, or here in the orchestrator.
|
|
9
|
+
*
|
|
10
|
+
* A step naming a workflow queues whenever a queue exists, even unmarked. Run
|
|
11
|
+
* here, it holds the parent's run lock — and its lock connection — until the
|
|
12
|
+
* child ends, and marks the child inline so the child's own `sleep` degrades
|
|
13
|
+
* from a suspension into a real in-process wait. Workflows cannot opt in
|
|
14
|
+
* through `workflowQueued`: that flag is read off `rpc` meta, and `addWorkflow`
|
|
15
|
+
* never registers there.
|
|
16
|
+
*
|
|
17
|
+
* Throws only for a step that asked for the queue by name and has none, which
|
|
18
|
+
* is a deployment missing a service rather than a routing choice.
|
|
19
|
+
*
|
|
20
|
+
* `parentIsInline` is a thunk because resolving it can read the run store, and
|
|
21
|
+
* every step dispatch would pay for that — only a step that names a workflow
|
|
22
|
+
* and has a queue to reach ever asks.
|
|
23
|
+
*/
|
|
24
|
+
export declare const stepDispatchTarget: (rpcName: string, stepName: string, parentIsInline: () => Promise<boolean>) => Promise<"queue" | "inline">;
|
|
7
25
|
export declare const jobGroupFor: (strategy: WorkflowQueueStrategy, id?: string) => JobGroup | undefined;
|
|
8
26
|
export declare const stepJobOptions: (stepOptions?: WorkflowStepOptions) => JobOptions;
|
|
@@ -25,6 +25,41 @@ const dedicatedQueueName = (prefix, name, strategy, fallback) => {
|
|
|
25
25
|
};
|
|
26
26
|
export const orchestratorQueueName = (strategy, workflowName) => dedicatedQueueName('wf-orchestrator-', workflowName, strategy, resolveWorkflowConfig().orchestratorQueueName);
|
|
27
27
|
export const stepWorkerQueueName = (strategy, rpcName) => dedicatedQueueName('wf-step-', rpcName, strategy, resolveWorkflowConfig().stepWorkerQueueName);
|
|
28
|
+
/**
|
|
29
|
+
* How a step reaches its worker: on the queue, or here in the orchestrator.
|
|
30
|
+
*
|
|
31
|
+
* A step naming a workflow queues whenever a queue exists, even unmarked. Run
|
|
32
|
+
* here, it holds the parent's run lock — and its lock connection — until the
|
|
33
|
+
* child ends, and marks the child inline so the child's own `sleep` degrades
|
|
34
|
+
* from a suspension into a real in-process wait. Workflows cannot opt in
|
|
35
|
+
* through `workflowQueued`: that flag is read off `rpc` meta, and `addWorkflow`
|
|
36
|
+
* never registers there.
|
|
37
|
+
*
|
|
38
|
+
* Throws only for a step that asked for the queue by name and has none, which
|
|
39
|
+
* is a deployment missing a service rather than a routing choice.
|
|
40
|
+
*
|
|
41
|
+
* `parentIsInline` is a thunk because resolving it can read the run store, and
|
|
42
|
+
* every step dispatch would pay for that — only a step that names a workflow
|
|
43
|
+
* and has a queue to reach ever asks.
|
|
44
|
+
*/
|
|
45
|
+
export const stepDispatchTarget = async (rpcName, stepName, parentIsInline) => {
|
|
46
|
+
const rpcFuncId = pikkuState(null, 'rpc', 'meta')[rpcName];
|
|
47
|
+
const rpcMeta = typeof rpcFuncId === 'string'
|
|
48
|
+
? pikkuState(null, 'function', 'meta')[rpcFuncId]
|
|
49
|
+
: undefined;
|
|
50
|
+
const hasQueue = getSingletonServices()?.queueService !== undefined;
|
|
51
|
+
if (rpcMeta?.workflowQueued === true) {
|
|
52
|
+
if (!hasQueue) {
|
|
53
|
+
throw new Error(`Workflow step '${stepName}' (function '${rpcName}') is marked 'workflowQueued: true' but no queue service is configured.`);
|
|
54
|
+
}
|
|
55
|
+
return 'queue';
|
|
56
|
+
}
|
|
57
|
+
const isWorkflow = pikkuState(null, 'workflows', 'meta')[rpcName] !== undefined;
|
|
58
|
+
if (!isWorkflow || !hasQueue) {
|
|
59
|
+
return 'inline';
|
|
60
|
+
}
|
|
61
|
+
return (await parentIsInline()) ? 'inline' : 'queue';
|
|
62
|
+
};
|
|
28
63
|
export const jobGroupFor = (strategy, id) => id && strategy === 'shared-groups' ? { id, tier: id } : undefined;
|
|
29
64
|
export const stepJobOptions = (stepOptions) => {
|
|
30
65
|
const retries = stepOptions?.retries ?? DEFAULT_STEP_RETRIES;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CoreUserSession } from '../../types/core.types.js';
|
|
2
|
+
import type { PikkuChannel } from '../channel/channel.types.js';
|
|
3
|
+
import type { WorkflowRunService } from './workflow.types.js';
|
|
4
|
+
export interface WorkflowStatusStreamParams {
|
|
5
|
+
workflowRunService: WorkflowRunService;
|
|
6
|
+
runId: string;
|
|
7
|
+
channel: Pick<PikkuChannel<unknown, any>, 'send' | 'close'>;
|
|
8
|
+
session: CoreUserSession | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Whether to include what the run produced. Off for the user-facing route:
|
|
11
|
+
* a workflow's output and its error messages are internal detail, and a step
|
|
12
|
+
* that spawned a child run says so only to tooling that can follow it.
|
|
13
|
+
*/
|
|
14
|
+
detailed?: boolean;
|
|
15
|
+
pollIntervalMs?: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Streams one run's progress until it reaches a terminal state.
|
|
19
|
+
*
|
|
20
|
+
* Polled rather than subscribed because a run's steps are written by whichever
|
|
21
|
+
* worker picked them up, in whichever process — there is no in-memory event to
|
|
22
|
+
* listen for that every deployment shape would deliver.
|
|
23
|
+
*
|
|
24
|
+
* Each poll sends only when something changed, compared by a hash of exactly
|
|
25
|
+
* what this stream reports. A run that sits on a slow step for a minute costs
|
|
26
|
+
* one message, not a hundred and twenty.
|
|
27
|
+
*/
|
|
28
|
+
export declare const streamWorkflowRunStatus: ({ workflowRunService, runId, channel, session, detailed, pollIntervalMs, }: WorkflowStatusStreamParams) => Promise<void>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { assertWorkflowRunOwner } from './workflow-run-ownership.js';
|
|
2
|
+
/**
|
|
3
|
+
* The status stream behind the scaffolded workflow SSE routes.
|
|
4
|
+
*
|
|
5
|
+
* Two routes share it, and they differ by one thing: whether the caller is
|
|
6
|
+
* trusted with what the run produced. A user-facing frontend gets step names
|
|
7
|
+
* and statuses; an admin console also gets the output, the error and the child
|
|
8
|
+
* run ids. That is a parameter, not a second copy of the loop.
|
|
9
|
+
*/
|
|
10
|
+
const TERMINAL = new Set([
|
|
11
|
+
'completed',
|
|
12
|
+
'failed',
|
|
13
|
+
'cancelled',
|
|
14
|
+
]);
|
|
15
|
+
const DEFAULT_POLL_INTERVAL_MS = 500;
|
|
16
|
+
/**
|
|
17
|
+
* Streams one run's progress until it reaches a terminal state.
|
|
18
|
+
*
|
|
19
|
+
* Polled rather than subscribed because a run's steps are written by whichever
|
|
20
|
+
* worker picked them up, in whichever process — there is no in-memory event to
|
|
21
|
+
* listen for that every deployment shape would deliver.
|
|
22
|
+
*
|
|
23
|
+
* Each poll sends only when something changed, compared by a hash of exactly
|
|
24
|
+
* what this stream reports. A run that sits on a slow step for a minute costs
|
|
25
|
+
* one message, not a hundred and twenty.
|
|
26
|
+
*/
|
|
27
|
+
export const streamWorkflowRunStatus = async ({ workflowRunService, runId, channel, session, detailed = false, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, }) => {
|
|
28
|
+
let lastHash = '';
|
|
29
|
+
let initSent = false;
|
|
30
|
+
const poll = async () => {
|
|
31
|
+
const run = await workflowRunService.getRun(runId);
|
|
32
|
+
if (!run) {
|
|
33
|
+
await channel.close();
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
// Checked on every poll, not just the first: ownership is read from the run
|
|
37
|
+
// itself, and a stream that outlives a session should stop rather than keep
|
|
38
|
+
// reporting.
|
|
39
|
+
assertWorkflowRunOwner(run.wire, session);
|
|
40
|
+
const steps = await workflowRunService.getRunSteps(runId);
|
|
41
|
+
// A deterministic run knows its whole shape up front, so the client can
|
|
42
|
+
// draw every step — including the ones not started — before anything runs.
|
|
43
|
+
// A dynamic run has nothing to send here, and gets no init frame.
|
|
44
|
+
if (!initSent && run.deterministic) {
|
|
45
|
+
const statusByStep = new Map(steps.map((step) => [step.stepName, step.status]));
|
|
46
|
+
await channel.send({
|
|
47
|
+
type: 'init',
|
|
48
|
+
deterministic: true,
|
|
49
|
+
steps: (run.plannedSteps ?? []).map((step) => ({
|
|
50
|
+
stepName: step.stepName,
|
|
51
|
+
status: statusByStep.get(step.stepName) ?? 'pending',
|
|
52
|
+
})),
|
|
53
|
+
});
|
|
54
|
+
initSent = true;
|
|
55
|
+
}
|
|
56
|
+
const hash = JSON.stringify({
|
|
57
|
+
s: run.status,
|
|
58
|
+
...(detailed ? { o: run.output } : {}),
|
|
59
|
+
steps: steps.map((step) => [step.stepName, step.status]),
|
|
60
|
+
});
|
|
61
|
+
if (hash !== lastHash) {
|
|
62
|
+
lastHash = hash;
|
|
63
|
+
await channel.send({
|
|
64
|
+
type: 'update',
|
|
65
|
+
status: run.status,
|
|
66
|
+
...(detailed ? { output: run.output, error: run.error } : {}),
|
|
67
|
+
steps: steps.map((step) => ({
|
|
68
|
+
stepName: step.stepName,
|
|
69
|
+
status: step.status,
|
|
70
|
+
...(detailed && step.childRunId
|
|
71
|
+
? { childRunId: step.childRunId }
|
|
72
|
+
: {}),
|
|
73
|
+
})),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (TERMINAL.has(run.status)) {
|
|
77
|
+
await channel.send({ type: 'done' });
|
|
78
|
+
await channel.close();
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
};
|
|
83
|
+
// Every exit from here closes the channel, including the ones a throw takes:
|
|
84
|
+
// `assertWorkflowRunOwner` rejecting a session that lost access is exactly
|
|
85
|
+
// the case where the stream should end rather than be left hanging open.
|
|
86
|
+
try {
|
|
87
|
+
// A run that is already finished is answered without ever starting a timer.
|
|
88
|
+
if (!(await poll())) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// The next poll is scheduled when the previous one resolves rather than on
|
|
92
|
+
// a fixed interval. A timer that fires regardless would let two polls
|
|
93
|
+
// overlap on a slow store — both seeing `initSent` unset and sending the
|
|
94
|
+
// init frame twice, and racing `lastHash` into out-of-order updates.
|
|
95
|
+
while (await new Promise((resolve, reject) => {
|
|
96
|
+
setTimeout(() => void poll().then(resolve, reject), pollIntervalMs);
|
|
97
|
+
})) {
|
|
98
|
+
// The condition is the whole loop: poll until it says to stop.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
await channel.close();
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
};
|
package/package.json
CHANGED
|
@@ -215,6 +215,48 @@ describe('pikkuDevReloader', { concurrency: false }, () => {
|
|
|
215
215
|
assert.deepEqual(await func.func({} as any, {}, {} as any), {
|
|
216
216
|
working: true,
|
|
217
217
|
})
|
|
218
|
+
|
|
219
|
+
// Serving the old code is only safe if the developer is told why; without
|
|
220
|
+
// the reason the sole symptom is a function that ignores the file on disk.
|
|
221
|
+
const failureLog = mockLogger
|
|
222
|
+
.getLogs()
|
|
223
|
+
.find((l) => l.message.includes('Failed to import'))
|
|
224
|
+
assert.ok(failureLog, 'Should log the failed import')
|
|
225
|
+
assert.ok(
|
|
226
|
+
failureLog!.message.includes('keeping old code'),
|
|
227
|
+
'Should say the old code is still being served'
|
|
228
|
+
)
|
|
229
|
+
assert.match(failureLog!.message, /badFunc\.js/)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('should name the top-level await limitation when a reload hits it', async (t) => {
|
|
233
|
+
if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
|
|
234
|
+
|
|
235
|
+
await writeFile(join(tmpDir, 'tlaFunc.ts'), '// initial')
|
|
236
|
+
|
|
237
|
+
reloader = await pikkuDevReloader({
|
|
238
|
+
srcDirectories: [tmpDir],
|
|
239
|
+
logger: mockLogger,
|
|
240
|
+
pikkuDir: tmpDir,
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
await writeFile(
|
|
244
|
+
join(tmpDir, 'tlaFunc.ts'),
|
|
245
|
+
`const config = await Promise.resolve({ ok: true })
|
|
246
|
+
export const tlaFunc = { func: async () => config }
|
|
247
|
+
// trigger ${Date.now()}`
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
await wait(300)
|
|
251
|
+
|
|
252
|
+
const failureLog = mockLogger
|
|
253
|
+
.getLogs()
|
|
254
|
+
.find((l) => l.message.includes('Failed to import'))
|
|
255
|
+
assert.ok(failureLog, 'Should log the failed import')
|
|
256
|
+
// The file is valid TypeScript; pointing at pikku's own `cjs` emit is the
|
|
257
|
+
// difference between a two-minute fix and an afternoon.
|
|
258
|
+
assert.match(failureLog!.message, /top-level `?await`?/i)
|
|
259
|
+
assert.match(failureLog!.message, /pikku limitation/i)
|
|
218
260
|
})
|
|
219
261
|
|
|
220
262
|
test('should ignore non-ts files, test files, and gen files', async (t) => {
|
package/src/dev/hot-reload.ts
CHANGED
|
@@ -9,7 +9,10 @@ import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middlewa
|
|
|
9
9
|
import { httpRouter } from '../wirings/http/routers/http-router.js'
|
|
10
10
|
import type { Logger } from '../services/logger.js'
|
|
11
11
|
import type { CorePikkuFunctionConfig } from '../function/functions.types.js'
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
createModuleRunner,
|
|
14
|
+
isTopLevelAwaitLimitation,
|
|
15
|
+
} from './module-runner.js'
|
|
13
16
|
|
|
14
17
|
export { reloadGeneratedMeta, reconcileAddonRegistry } from './reload-meta.js'
|
|
15
18
|
|
|
@@ -63,6 +66,23 @@ const isWatchedTsFile = (filename: string): boolean => {
|
|
|
63
66
|
)
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
/** Not every reload failure is a mistake in the file: pikku's reloader emits
|
|
70
|
+
* `cjs`, which has no way to express top-level `await`, so a perfectly valid
|
|
71
|
+
* module can fail here forever. Saying so outright saves the reader from
|
|
72
|
+
* hunting a bug that is not in their code. The stack is dropped in that case
|
|
73
|
+
* because it points into esbuild rather than at anything actionable. */
|
|
74
|
+
const reloadFailureReason = (error: Error): string => {
|
|
75
|
+
if (isTopLevelAwaitLimitation(error)) {
|
|
76
|
+
return (
|
|
77
|
+
` ${error.message}\n` +
|
|
78
|
+
' This is a pikku limitation, not a mistake in your file: the hot-reloader compiles to `cjs`, ' +
|
|
79
|
+
'which cannot express top-level `await`. Move the awaited work into a function, or restart the ' +
|
|
80
|
+
'dev server to pick the file up.'
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
return ` ${error.stack ?? error.message}`
|
|
84
|
+
}
|
|
85
|
+
|
|
66
86
|
export interface PikkuDevReloaderHandle {
|
|
67
87
|
close: () => void
|
|
68
88
|
/** Re-import every file changed since the last drain (post-codegen, once
|
|
@@ -96,13 +116,19 @@ export async function pikkuDevReloader(
|
|
|
96
116
|
)
|
|
97
117
|
const importPath = compiledFile ?? changedTsFile
|
|
98
118
|
|
|
99
|
-
const
|
|
100
|
-
if (!
|
|
119
|
+
const result = await moduleRunner.run(importPath)
|
|
120
|
+
if (!result.ok) {
|
|
121
|
+
// Keeping the old code leaves the process disagreeing with the file on
|
|
122
|
+
// disk, and the only symptom is stale output from a function that looks
|
|
123
|
+
// correct in the editor — so the reason has to be printed here, where it
|
|
124
|
+
// is still known, rather than left for the developer to reconstruct.
|
|
101
125
|
logger.error(
|
|
102
|
-
`Failed to import: ${relative(process.cwd(), importPath)} (keeping old code)`
|
|
126
|
+
`Failed to import: ${relative(process.cwd(), importPath)} (keeping old code)\n` +
|
|
127
|
+
reloadFailureReason(result.error)
|
|
103
128
|
)
|
|
104
129
|
return
|
|
105
130
|
}
|
|
131
|
+
const mod = result.exports
|
|
106
132
|
|
|
107
133
|
// knowledge: decisions/internals/hot-reload-writes-into-the-function-map-captured-at-startup.md
|
|
108
134
|
for (const [exportName, exportValue] of Object.entries(mod)) {
|
|
@@ -8,7 +8,10 @@ import { join } from 'node:path'
|
|
|
8
8
|
import { pathToFileURL } from 'node:url'
|
|
9
9
|
import { tmpdir } from 'node:os'
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
createModuleRunner,
|
|
13
|
+
isTopLevelAwaitLimitation,
|
|
14
|
+
} from './module-runner.js'
|
|
12
15
|
|
|
13
16
|
// A forced-GC hook without launching the process with a flag: on Bun use the
|
|
14
17
|
// native collector; on Node flip --expose-gc on just long enough to grab `gc`.
|
|
@@ -56,9 +59,10 @@ describe('createModuleRunner', { concurrency: false }, () => {
|
|
|
56
59
|
export const createTodo = { func: async (_s: any, d: Todo) => ({ id: d.id }) }`
|
|
57
60
|
)
|
|
58
61
|
|
|
59
|
-
const
|
|
60
|
-
assert.ok
|
|
61
|
-
const createTodo =
|
|
62
|
+
const result = await runner.run(file)
|
|
63
|
+
assert.equal(result.ok, true)
|
|
64
|
+
const createTodo = (result as { exports: Record<string, unknown> }).exports
|
|
65
|
+
.createTodo as {
|
|
62
66
|
func: (...a: any[]) => Promise<any>
|
|
63
67
|
}
|
|
64
68
|
assert.equal(typeof createTodo.func, 'function')
|
|
@@ -83,8 +87,9 @@ describe('createModuleRunner', { concurrency: false }, () => {
|
|
|
83
87
|
wire('createTodo', createTodo)`
|
|
84
88
|
)
|
|
85
89
|
|
|
86
|
-
const
|
|
87
|
-
assert.ok
|
|
90
|
+
const result = await runner.run(userFile)
|
|
91
|
+
assert.equal(result.ok, true)
|
|
92
|
+
const mod = (result as { exports: Record<string, unknown> }).exports
|
|
88
93
|
|
|
89
94
|
// Read the dependency through the same resolver the runner uses, so we
|
|
90
95
|
// observe the exact instance the user module's `import` bound to (using a
|
|
@@ -103,26 +108,64 @@ describe('createModuleRunner', { concurrency: false }, () => {
|
|
|
103
108
|
|
|
104
109
|
await writeFile(file, `export const value = { func: async () => 'v1' }`)
|
|
105
110
|
const first = await runner.run(file)
|
|
106
|
-
assert.equal(
|
|
111
|
+
assert.equal(first.ok, true)
|
|
112
|
+
assert.equal(await ((first as any).exports.value as any).func(), 'v1')
|
|
107
113
|
|
|
108
114
|
await writeFile(file, `export const value = { func: async () => 'v2' }`)
|
|
109
115
|
const second = await runner.run(file)
|
|
110
|
-
assert.equal(
|
|
116
|
+
assert.equal(second.ok, true)
|
|
117
|
+
assert.equal(await ((second as any).exports.value as any).func(), 'v2')
|
|
111
118
|
|
|
112
119
|
// Stable key: many reloads of one path never grow the registry.
|
|
113
120
|
for (let i = 0; i < 20; i++) await runner.run(file)
|
|
114
121
|
assert.equal(runner.size, 1)
|
|
115
122
|
})
|
|
116
123
|
|
|
117
|
-
test('
|
|
124
|
+
test('reports a bad edit with its reason so the caller can say why', async () => {
|
|
118
125
|
const runner = createModuleRunner()
|
|
119
126
|
const file = join(tmpDir, 'broken.ts')
|
|
120
127
|
await writeFile(
|
|
121
128
|
file,
|
|
122
129
|
`export const oops = { func: async () => ( } ] syntax`
|
|
123
130
|
)
|
|
124
|
-
const
|
|
125
|
-
assert.equal(
|
|
131
|
+
const result = await runner.run(file)
|
|
132
|
+
assert.equal(result.ok, false)
|
|
133
|
+
// The caller keeps serving the old code, so this error is the only thing
|
|
134
|
+
// standing between the developer and an unexplained stale response.
|
|
135
|
+
const { error } = result as { error: Error }
|
|
136
|
+
assert.ok(error instanceof Error)
|
|
137
|
+
assert.match(error.message, /broken\.ts/)
|
|
138
|
+
assert.equal(isTopLevelAwaitLimitation(error), false)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('names the top-level await limitation as such', async () => {
|
|
142
|
+
const runner = createModuleRunner()
|
|
143
|
+
const file = join(tmpDir, 'tla.ts')
|
|
144
|
+
await writeFile(
|
|
145
|
+
file,
|
|
146
|
+
`const config = await Promise.resolve({ ok: true })
|
|
147
|
+
export const load = { func: async () => config }`
|
|
148
|
+
)
|
|
149
|
+
const result = await runner.run(file)
|
|
150
|
+
assert.equal(result.ok, false)
|
|
151
|
+
// Nothing is wrong with this file — the `cjs` emit is what cannot take it,
|
|
152
|
+
// and the caller has to be able to tell the developer that.
|
|
153
|
+
assert.equal(
|
|
154
|
+
isTopLevelAwaitLimitation((result as { error: Error }).error),
|
|
155
|
+
true
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('a thrown non-Error still arrives as an Error carrying its value', async () => {
|
|
160
|
+
const runner = createModuleRunner()
|
|
161
|
+
const file = join(tmpDir, 'throws-a-string.ts')
|
|
162
|
+
await writeFile(file, `throw 'boom'`)
|
|
163
|
+
const result = await runner.run(file)
|
|
164
|
+
assert.equal(result.ok, false)
|
|
165
|
+
const { error } = result as { error: Error }
|
|
166
|
+
assert.ok(error instanceof Error)
|
|
167
|
+
assert.equal(error.message, 'boom')
|
|
168
|
+
assert.equal((error as { cause?: unknown }).cause, 'boom')
|
|
126
169
|
})
|
|
127
170
|
|
|
128
171
|
test('editing and reimporting a module 200x does not leak memory', async () => {
|
|
@@ -153,8 +196,8 @@ describe('createModuleRunner', { concurrency: false }, () => {
|
|
|
153
196
|
|
|
154
197
|
for (let i = 1; i <= 200; i++) {
|
|
155
198
|
await write(i)
|
|
156
|
-
const
|
|
157
|
-
assert.ok
|
|
199
|
+
const result = await runner.run(file)
|
|
200
|
+
assert.equal(result.ok, true)
|
|
158
201
|
}
|
|
159
202
|
gc()
|
|
160
203
|
const growth = heapUsedMb() - baseline
|
package/src/dev/module-runner.ts
CHANGED
|
@@ -18,22 +18,35 @@ const loadTransform = async (): Promise<EsbuildTransform> => {
|
|
|
18
18
|
return transformSync
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** The outcome of one run. A failure carries its error rather than collapsing
|
|
22
|
+
* to `null`: the caller keeps serving the previously-loaded code, so unless the
|
|
23
|
+
* reason travels with the failure the running process silently disagrees with
|
|
24
|
+
* the file on disk and nothing anywhere says why. */
|
|
25
|
+
export type PikkuModuleRunResult =
|
|
26
|
+
{ ok: true; exports: Record<string, unknown> } | { ok: false; error: Error }
|
|
27
|
+
|
|
21
28
|
export interface PikkuModuleRunner {
|
|
22
29
|
/** Run a user module by absolute path. Repeated runs of one path overwrite a
|
|
23
|
-
* single registry slot.
|
|
24
|
-
* previously-loaded code
|
|
25
|
-
|
|
30
|
+
* single registry slot. Failure is returned, not thrown, so the caller can
|
|
31
|
+
* keep the previously-loaded code — and the discriminant makes that case
|
|
32
|
+
* impossible to read past by accident. */
|
|
33
|
+
run: (absPath: string) => Promise<PikkuModuleRunResult>
|
|
26
34
|
evict: (absPath: string) => void
|
|
27
35
|
clear: () => void
|
|
28
36
|
readonly size: number
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
/** esbuild states pikku's one documented reload limitation only in the text of
|
|
40
|
+
* its transform error. Matching it is worth the fragility: the developer's file
|
|
41
|
+
* is correct, and no amount of re-reading it will reveal that the reloader —
|
|
42
|
+
* not the file — is what cannot cope. */
|
|
43
|
+
export const isTopLevelAwaitLimitation = (error: Error): boolean =>
|
|
44
|
+
/top-level await/i.test(error.message)
|
|
45
|
+
|
|
31
46
|
export const createModuleRunner = (): PikkuModuleRunner => {
|
|
32
47
|
const registry = new Map<string, Record<string, unknown>>()
|
|
33
48
|
|
|
34
|
-
const run = async (
|
|
35
|
-
filePath: string
|
|
36
|
-
): Promise<Record<string, unknown> | null> => {
|
|
49
|
+
const run = async (filePath: string): Promise<PikkuModuleRunResult> => {
|
|
37
50
|
const absPath = resolve(filePath)
|
|
38
51
|
try {
|
|
39
52
|
const transform = await loadTransform()
|
|
@@ -55,11 +68,20 @@ export const createModuleRunner = (): PikkuModuleRunner => {
|
|
|
55
68
|
fn(require, moduleObj.exports, moduleObj, absPath, dirname(absPath))
|
|
56
69
|
|
|
57
70
|
registry.set(absPath, moduleObj.exports)
|
|
58
|
-
return moduleObj.exports
|
|
59
|
-
} catch {
|
|
71
|
+
return { ok: true, exports: moduleObj.exports }
|
|
72
|
+
} catch (thrown) {
|
|
60
73
|
// A bad edit, or the one known limitation: a file using top-level
|
|
61
|
-
// `await`, which cannot be emitted in `cjs` form.
|
|
62
|
-
|
|
74
|
+
// `await`, which cannot be emitted in `cjs` form. Normalised to an
|
|
75
|
+
// `Error` so the caller always has a message and a stack to print
|
|
76
|
+
// without re-deriving them; a non-`Error` throw keeps its original value
|
|
77
|
+
// as the `cause`.
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error:
|
|
81
|
+
thrown instanceof Error
|
|
82
|
+
? thrown
|
|
83
|
+
: new Error(String(thrown), { cause: thrown }),
|
|
84
|
+
}
|
|
63
85
|
}
|
|
64
86
|
}
|
|
65
87
|
|