@pikku/core 0.12.70 → 0.12.71

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/LICENSE +21 -0
  3. package/dist/services/in-memory-queue-service.d.ts +6 -0
  4. package/dist/services/in-memory-queue-service.js +8 -1
  5. package/dist/services/in-memory-workflow-service.d.ts +3 -5
  6. package/dist/services/in-memory-workflow-service.js +10 -19
  7. package/dist/services/workflow-service.d.ts +7 -5
  8. package/dist/types/core.types.d.ts +7 -0
  9. package/dist/wirings/ai-agent/ai-agent-agui.js +0 -8
  10. package/dist/wirings/ai-agent/ai-agent-prepare.js +1 -2
  11. package/dist/wirings/ai-agent/ai-agent.types.d.ts +0 -6
  12. package/dist/wirings/workflow/graph/graph-runner.js +3 -2
  13. package/dist/wirings/workflow/graph/graph-validation.d.ts +0 -2
  14. package/dist/wirings/workflow/graph/graph-validation.js +0 -142
  15. package/dist/wirings/workflow/graph/index.d.ts +1 -1
  16. package/dist/wirings/workflow/graph/index.js +1 -1
  17. package/dist/wirings/workflow/index.d.ts +0 -1
  18. package/dist/wirings/workflow/index.js +0 -2
  19. package/dist/wirings/workflow/pikku-workflow-service.d.ts +69 -15
  20. package/dist/wirings/workflow/pikku-workflow-service.js +260 -164
  21. package/dist/wirings/workflow/workflow.types.d.ts +1 -6
  22. package/package.json +1 -1
  23. package/src/services/in-memory-queue-service.test.ts +66 -1
  24. package/src/services/in-memory-queue-service.ts +13 -2
  25. package/src/services/in-memory-workflow-service.ts +12 -25
  26. package/src/services/workflow-service.ts +7 -4
  27. package/src/types/core.types.ts +7 -0
  28. package/src/wirings/ai-agent/ai-agent-agui.test.ts +0 -16
  29. package/src/wirings/ai-agent/ai-agent-agui.ts +0 -9
  30. package/src/wirings/ai-agent/ai-agent-prepare.ts +1 -2
  31. package/src/wirings/ai-agent/ai-agent.types.ts +0 -7
  32. package/src/wirings/workflow/graph/graph-runner.ts +3 -2
  33. package/src/wirings/workflow/graph/graph-validation.test.ts +1 -144
  34. package/src/wirings/workflow/graph/graph-validation.ts +0 -196
  35. package/src/wirings/workflow/graph/index.ts +1 -5
  36. package/src/wirings/workflow/index.ts +0 -6
  37. package/src/wirings/workflow/pikku-workflow-service.ts +377 -212
  38. package/src/wirings/workflow/scenario-expectations.test.ts +153 -0
  39. package/src/wirings/workflow/scenario-step.test.ts +1 -1
  40. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +1 -1
  41. package/src/wirings/workflow/workflow-dispatch-payload.test.ts +59 -0
  42. package/src/wirings/workflow/workflow-mirror.test.ts +178 -0
  43. package/src/wirings/workflow/workflow-replay-snapshot.test.ts +139 -0
  44. package/src/wirings/workflow/workflow-run-context.test.ts +177 -0
  45. package/src/wirings/workflow/workflow-run-polling.test.ts +132 -0
  46. package/src/wirings/workflow/workflow-step-ordinal.test.ts +4 -4
  47. package/src/wirings/workflow/workflow.types.ts +1 -4
  48. package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,91 @@
1
+ ## 0.12.71
2
+
3
+ ### Patch Changes
4
+
5
+ - 8a2c993: Make the workflow service cheaper to run, and fix two ways it lost state.
6
+
7
+ The SQL workflow tables had no indexes at all, so every step read, every
8
+ history walk and every orchestrator tick was a sequential scan; five indexes
9
+ now cover the columns the engine actually queries by. A replay used to ask for
10
+ each step's row individually — O(N) reads per replay, O(N^2) over a run — and
11
+ now takes one read of the run's steps and serves the walk from it. A step
12
+ transition wrote the step row and its history row as two separate statements,
13
+ so a crash between them left a step saying `succeeded` whose history still said
14
+ `running`; both halves are now one transaction, and the history row is found by
15
+ attempt number rather than by sorting on `created_at`, which two attempts can
16
+ share. Resolving a dynamic workflow read and parsed every AI-generated workflow
17
+ in the deployment to `.find()` one by name; it is a point lookup now.
18
+
19
+ Waiting on a run no longer polls at a fixed interval. `pollIntervalMs` became a
20
+ ceiling rather than a cadence: polling starts at 10ms and widens towards it, so
21
+ a workflow that finishes in milliseconds is no longer held for a full second,
22
+ and a long-running one is not read at full rate for its whole life.
23
+
24
+ Two backend-specific defects: Redis kept a run's state as one JSON blob and
25
+ read-modified-wrote it, so parallel branches setting different variables
26
+ overwrote each other — state is a field per variable now, with the old blob
27
+ still read underneath so runs in flight keep what they had. Mongo's
28
+ `setStepScheduled` never wrote history, leaving a queued step reading as never
29
+ dispatched.
30
+
31
+ Also: dispatch no longer JSON round-trips every step payload before handing it
32
+ to a queue that serialises it anyway — the in-process dev queue, which is the
33
+ only one that was relying on it, does it itself now.
34
+
35
+ Two more defects. A transition whose step had no live attempt wrote its status
36
+ to the step row and silently nothing to history — the exact divergence the
37
+ transaction exists to prevent — and now repairs the step and writes the
38
+ missing row. And resolving a dynamic workflow was non-deterministic on all
39
+ three backends: a name can hold several active versions, and none of them
40
+ ordered the candidates, so which one ran could change between two calls
41
+ reading identical data. The newest version wins, with the graph hash breaking
42
+ a tie.
43
+
44
+ The two attempt columns and the five indexes are declared in the workflow
45
+ schema, so a fresh database gets them at boot. An existing one gets them from
46
+ a migration — `pikku db generate` writes the declaration down — rather than
47
+ from DDL issued at boot.
48
+
49
+ - a261006: **Breaking:** removed dynamic workflows — runtime-defined workflow graphs stored in the database and resolved by name instead of by codegen.
50
+
51
+ The feature was already half-gone. Its authoring surface (`createAgentWorkflow`, `saveAgentWorkflow`, `listAgentWorkflows`, `executeAgentWorkflow`, and the AI-agent instruction builder) was deleted in April 2026 along with its entire e2e suite, and nothing has written a dynamic workflow since. What remained could not execute one either: `executeAgentWorkflow` gated on `pikkuState('workflows', 'meta')`, which only codegen ever populates, so a graph that existed solely in the database was never findable. The two backend families had also drifted onto different `source` sentinels (`'ai-agent'` vs `'dynamic-workflow'`), and the two Redis implementations disagreed on key escaping — so at least one of them matched nothing. Rather than keep shipping plumbing for a path no caller could complete, it is removed until it can be reintroduced deliberately.
52
+
53
+ Removed:
54
+ - `getAIGeneratedWorkflows` from `WorkflowService` and `WorkflowRunService`, and from every backend (in-memory, Redis, MongoDB, Kysely, and the Cloudflare Durable Object service and client — the last two were already a `return []` stub and a rejection).
55
+ - The database-lookup fallbacks in `startWorkflow` and `runWorkflowJob` that resolved a workflow name against stored graphs when static meta had no match.
56
+ - `'dynamic-workflow'` from the `WorkflowRuntimeMeta['source']` union.
57
+ - `validateWorkflowWiring` and `computeEntryNodeIds` from `@pikku/core/workflow`. These validated AI-authored graphs and had no callers in core; the inspector keeps its own private entry-node computation for static graph wiring, which is unaffected.
58
+ - The `workflow-created` AI stream event and its AG-UI `pikku:workflow-created` custom event. Its only emitter went with the April deletion, so it could never fire.
59
+ - The console's `console:getAIWorkflows` RPC, the `useAIWorkflows` hook, the "Dynamic" workflow filter and badge, and the trigger-schema scraper that derived an input form from a stored graph's `$ref` bindings.
60
+
61
+ Kept, because static graph workflows depend on them and this is not a change to versioning:
62
+ - `upsertWorkflowVersion`, `getWorkflowVersion`, `updateWorkflowVersionStatus`, and the `workflowVersions` storage in every backend. These back version-mismatch replay: when a deployed graph's hash changes, in-flight runs continue against the exact graph they started on. No schema migration is needed — the table, its columns, and its `(workflowName, graphHash)` upsert key are unchanged.
63
+ - `generateMermaidDiagram`, which renders any workflow graph and is not specific to dynamic ones.
64
+
65
+ Static `pikkuWorkflowGraph` and DSL workflows are entirely unaffected: they resolve from codegen'd meta, which was always the only path that worked.
66
+
67
+ To revive this post-MVP, the deleted authoring code is recoverable in full — its prompt engineering (a compact tool table upfront, full schemas with flattened dotted output paths returned only after a validation failure) is worth reading before rewriting:
68
+
69
+ ```
70
+ git show f52f3308b^:packages/core/src/wirings/ai-agent/agent-dynamic-workflow.ts
71
+ git show f52f3308b^:packages/core/src/wirings/workflow/graph/graph-validation.ts
72
+ git show f52f3308b --stat # the April removal, incl. the three e2e feature files
73
+ ```
74
+
75
+ Note that reviving it needs more than restoring those files: the queued-step path (`executeWorkflowStep`), `onError` compensation, and sub-workflow resolution all read static meta only and would need a fallback for a graph that exists solely in the database.
76
+
77
+ - 09973b9: Scenarios, features and steps no longer reach a deployment.
78
+
79
+ Steps were already held back from the app bootstrap, so a deployed server never imported a step body. Everything _about_ a scenario still travelled with the application: a `pikkuScenario(...)` is a function, so its name, schemas and hashes sat in the app function meta; the schemas it and its steps validate against sat in the app's `register.gen.ts` — on one project 458 of the 582 registered schemas belonged to tests; its name sat in the internal RPC meta; and because a scenario is _also_ a workflow, the inspector synthesised a `wf-orchestrator-<scenario>` queue worker for each one. The deploy analyzer, which reads inspector state rather than the partitioned codegen output, then read all of it back as application code: a unit per scenario, a `WorkflowDefinition` per scenario, and a real queue per scenario. A 13-scenario suite turned into 13 production queues named after tests, waiting for a provider to create them.
80
+
81
+ The existing scenario/app partition is now applied everywhere it was missing. `FunctionRuntimeMeta` gains a `scenario` marker (the counterpart of `scenarioStep`) so a scenario body is recognisable without walking the workflow graph; scenario bodies join their steps on the scenario side of the function-meta and registration split; schemas only a scenario or step needs are written and registered under `.pikku/scenarios/schemas/` and imported by the scenario bootstrap alone; scenario names are dropped from the internal RPC meta; no orchestrator queue worker is synthesised for a scenario; and the deploy analyzer drops both scenario functions and scenario workflows before it decides what a deployment contains.
82
+
83
+ The MCP metas are keyed by wiring rather than by function, so a scenario wired as an MCP tool, resource or prompt was the one id that still reached the manifest after the function and workflow filters — as an endpoint on the gateway plus a gateway dependency on a unit that was never emitted. Those ids are now filtered too.
84
+
85
+ `scenarioSchemaDirectory` is rejected when it resolves to the same directory as `schemaDirectory`. A schema write owns its directory — it emits `register.gen.ts` and prunes every schema file its own required-set does not name — so sharing one would replace the application register with the scenario-only one and delete the app's schema files, which nothing downstream can detect.
86
+
87
+ Nothing changes for `pikku scenario run` — the scenario bootstrap still registers every scenario, feature, step, meta and schema. What changes is that a bundle stops carrying them.
88
+
1
89
  ## 0.12.70
2
90
 
3
91
  ### Patch Changes
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 - present Yasser Fadl and Pikku contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -5,6 +5,12 @@ import type { QueueService, JobOptions } from '../wirings/queue/queue.types.js';
5
5
  * a real queue — and redelivers a failed job up to `options.attempts` times with
6
6
  * backoff, so a transiently-failing workflow step recovers exactly as it would
7
7
  * on pg-boss/bullmq instead of being silently dropped on its first error.
8
+ *
9
+ * Payloads are JSON round-tripped on the way in, because every real backend
10
+ * puts the job on a wire (SQS body, Redis value, jsonb column) and the worker
11
+ * therefore never sees the caller's live object. Doing it here keeps dev
12
+ * behaviour honest, and keeps the callers — who cannot know which backend they
13
+ * are talking to — from having to serialise defensively.
8
14
  */
9
15
  export declare class InMemoryQueueService implements QueueService {
10
16
  readonly supportsResults = false;
@@ -5,6 +5,12 @@ import { runQueueJob } from '../wirings/queue/queue-runner.js';
5
5
  * a real queue — and redelivers a failed job up to `options.attempts` times with
6
6
  * backoff, so a transiently-failing workflow step recovers exactly as it would
7
7
  * on pg-boss/bullmq instead of being silently dropped on its first error.
8
+ *
9
+ * Payloads are JSON round-tripped on the way in, because every real backend
10
+ * puts the job on a wire (SQS body, Redis value, jsonb column) and the worker
11
+ * therefore never sees the caller's live object. Doing it here keeps dev
12
+ * behaviour honest, and keeps the callers — who cannot know which backend they
13
+ * are talking to — from having to serialise defensively.
8
14
  */
9
15
  export class InMemoryQueueService {
10
16
  supportsResults = false;
@@ -14,12 +20,13 @@ export class InMemoryQueueService {
14
20
  const maxAttempts = Math.max(1, options?.attempts ?? 1);
15
21
  let attemptsMade = 0;
16
22
  const createdAt = new Date();
23
+ const payload = data === undefined ? data : JSON.parse(JSON.stringify(data));
17
24
  const runAttempt = async () => {
18
25
  attemptsMade++;
19
26
  const job = {
20
27
  id: jobId,
21
28
  queueName,
22
- data,
29
+ data: payload,
23
30
  status: () => 'active',
24
31
  metadata: () => ({ attemptsMade, maxAttempts, createdAt }),
25
32
  pikkuUserId: options?.pikkuUserId,
@@ -66,6 +66,9 @@ export declare class InMemoryWorkflowService extends PikkuWorkflowService implem
66
66
  branchKeys: Record<string, string>;
67
67
  }>;
68
68
  getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
69
+ protected listStepStates(runId: string): Promise<Array<StepState & {
70
+ stepName: string;
71
+ }>>;
69
72
  getStepInstances(runId: string): Promise<Array<{
70
73
  stepName: string;
71
74
  status: StepStatus;
@@ -82,9 +85,4 @@ export declare class InMemoryWorkflowService extends PikkuWorkflowService implem
82
85
  graph: any;
83
86
  source: string;
84
87
  } | null>;
85
- getAIGeneratedWorkflows(agentName?: string): Promise<Array<{
86
- workflowName: string;
87
- graphHash: string;
88
- graph: any;
89
- }>>;
90
88
  }
@@ -324,6 +324,16 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
324
324
  }
325
325
  return nodeIds.filter((id) => !existingSteps.has(id));
326
326
  }
327
+ async listStepStates(runId) {
328
+ const prefix = `${runId}:`;
329
+ const steps = [];
330
+ for (const [key, step] of this.steps.entries()) {
331
+ if (!key.startsWith(prefix))
332
+ continue;
333
+ steps.push({ ...step, stepName: key.substring(prefix.length) });
334
+ }
335
+ return steps;
336
+ }
327
337
  async getStepInstances(runId) {
328
338
  const prefix = `${runId}:`;
329
339
  const instances = [];
@@ -387,23 +397,4 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
387
397
  return null;
388
398
  return { graph: version.graph, source: version.source };
389
399
  }
390
- async getAIGeneratedWorkflows(agentName) {
391
- const results = [];
392
- const prefix = agentName ? `ai:${agentName}:` : 'ai:';
393
- for (const [key, value] of this.workflowVersions) {
394
- if (value.source !== 'ai-agent' || value.status !== 'active')
395
- continue;
396
- const separatorIdx = key.lastIndexOf(':');
397
- const wfName = key.substring(0, separatorIdx);
398
- const hash = key.substring(separatorIdx + 1);
399
- if (wfName.startsWith(prefix)) {
400
- results.push({
401
- workflowName: wfName,
402
- graphHash: hash,
403
- graph: value.graph,
404
- });
405
- }
406
- }
407
- return results;
408
- }
409
400
  }
@@ -35,6 +35,13 @@ export interface WorkflowService {
35
35
  }): Promise<{
36
36
  runId: string;
37
37
  }>;
38
+ /**
39
+ * Start a run and wait for it to end.
40
+ *
41
+ * `pollIntervalMs` is the ceiling on the wait between reads of the run, not a
42
+ * fixed cadence: polling starts far shorter than this and widens towards it,
43
+ * so a run that finishes quickly is not held for a whole interval.
44
+ */
38
45
  runToCompletion<I>(name: string, input: I, rpcService: any, options?: {
39
46
  pollIntervalMs?: number;
40
47
  wire?: WorkflowRunWire;
@@ -60,9 +67,4 @@ export interface WorkflowService {
60
67
  graph: any;
61
68
  source: string;
62
69
  } | null>;
63
- getAIGeneratedWorkflows(agentName?: string): Promise<Array<{
64
- workflowName: string;
65
- graphHash: string;
66
- graph: any;
67
- }>>;
68
70
  }
@@ -95,6 +95,13 @@ export type FunctionRuntimeMeta = {
95
95
  * may drive a browser or assert against fixtures.
96
96
  */
97
97
  scenarioStep?: boolean;
98
+ /**
99
+ * The function behind a `pikkuScenario(...)` — a scenario's own body, as
100
+ * opposed to the steps it calls. Marked for the same reason as
101
+ * `scenarioStep`: a scenario is only ever run by `pikku scenario run`, so it
102
+ * has to be held back from the app bootstrap and from every deployed unit.
103
+ */
104
+ scenario?: boolean;
98
105
  mcp?: boolean;
99
106
  readonly?: boolean;
100
107
  deploy?: 'serverless' | 'server' | 'auto';
@@ -256,14 +256,6 @@ export function wrapChannelWithAGUI(inner, options) {
256
256
  });
257
257
  break;
258
258
  }
259
- case 'workflow-created': {
260
- send({
261
- type: 'CUSTOM',
262
- name: 'pikku:workflow-created',
263
- value: { workflowName: event.workflowName, graph: event.graph },
264
- });
265
- break;
266
- }
267
259
  case 'agent-call': {
268
260
  send({
269
261
  type: 'CUSTOM',
@@ -312,8 +312,7 @@ export function createScopedChannel(parent, agentName, session) {
312
312
  event.type === 'tool-call' ||
313
313
  event.type === 'tool-result' ||
314
314
  event.type === 'usage' ||
315
- event.type === 'error' ||
316
- event.type === 'workflow-created') {
315
+ event.type === 'error') {
317
316
  parent.send({ ...event, agent: agentName, session });
318
317
  }
319
318
  else {
@@ -346,12 +346,6 @@ export type AIStreamEvent = {
346
346
  type: 'audio-done';
347
347
  agent?: string;
348
348
  session?: string;
349
- } | {
350
- type: 'workflow-created';
351
- workflowName: string;
352
- graph: any;
353
- agent?: string;
354
- session?: string;
355
349
  } | {
356
350
  type: 'data';
357
351
  name: string;
@@ -399,8 +399,9 @@ export async function continueGraph(workflowService, runId, graphName, overrideM
399
399
  }
400
400
  return;
401
401
  }
402
- const run = await workflowService.getRun(runId);
403
- const triggerInput = run?.input;
402
+ // The same run read a few lines up: nothing between here and there writes it,
403
+ // and `input` is fixed at creation anyway.
404
+ const triggerInput = currentRun?.input;
404
405
  for (const fire of plan.toFire) {
405
406
  const node = nodes[fire.logical];
406
407
  if (!node?.rpcName)
@@ -1,3 +1 @@
1
- export declare function computeEntryNodeIds(nodes: Record<string, any>): string[];
2
- export declare function validateWorkflowWiring(nodes: Record<string, any>, toolNames: string[]): string[];
3
1
  export declare function generateMermaidDiagram(workflowName: string, nodes: Record<string, any>, entryNodeIds: string[]): string;
@@ -1,5 +1,3 @@
1
- import { pikkuState } from '../../../pikku-state.js';
2
- import { resolveNamespace } from '../../rpc/rpc-runner.js';
3
1
  function normalizeTargets(value) {
4
2
  if (!value)
5
3
  return [];
@@ -16,146 +14,6 @@ function normalizeTargets(value) {
16
14
  }
17
15
  return [];
18
16
  }
19
- function collectRefs(value, refs) {
20
- if (typeof value === 'object' &&
21
- value !== null &&
22
- '$ref' in value &&
23
- typeof value.$ref === 'string') {
24
- refs.add(value.$ref);
25
- return;
26
- }
27
- if (Array.isArray(value)) {
28
- for (const item of value)
29
- collectRefs(item, refs);
30
- return;
31
- }
32
- if (typeof value === 'object' && value !== null) {
33
- for (const v of Object.values(value))
34
- collectRefs(v, refs);
35
- }
36
- }
37
- function resolveToolMeta(toolName) {
38
- const resolved = toolName.includes(':') ? resolveNamespace(toolName) : null;
39
- if (resolved) {
40
- const fnMeta = pikkuState(resolved.package, 'function', 'meta')[resolved.function];
41
- const schemas = pikkuState(resolved.package, 'misc', 'schemas');
42
- return fnMeta ? { fnMeta, schemas } : null;
43
- }
44
- const rpcMeta = pikkuState(null, 'rpc', 'meta');
45
- const pikkuFuncId = rpcMeta[toolName];
46
- if (!pikkuFuncId)
47
- return null;
48
- const fnMeta = pikkuState(null, 'function', 'meta')[pikkuFuncId];
49
- const schemas = pikkuState(null, 'misc', 'schemas');
50
- return fnMeta ? { fnMeta, schemas } : null;
51
- }
52
- export function computeEntryNodeIds(nodes) {
53
- const referenced = new Set();
54
- for (const node of Object.values(nodes)) {
55
- if (node.next) {
56
- for (const target of normalizeTargets(node.next)) {
57
- referenced.add(target);
58
- }
59
- }
60
- if (node.onError) {
61
- for (const target of normalizeTargets(node.onError)) {
62
- referenced.add(target);
63
- }
64
- }
65
- }
66
- return Object.keys(nodes).filter((id) => !referenced.has(id));
67
- }
68
- export function validateWorkflowWiring(nodes, toolNames) {
69
- const errors = [];
70
- const nodeIds = new Set(Object.keys(nodes));
71
- const toolSet = new Set(toolNames);
72
- for (const [nodeId, node] of Object.entries(nodes)) {
73
- if (!node.rpcName) {
74
- errors.push(`Node '${nodeId}' is missing 'rpcName'`);
75
- continue;
76
- }
77
- if (!toolSet.has(node.rpcName)) {
78
- errors.push(`Node '${nodeId}' references unknown tool '${node.rpcName}'. Available tools: ${toolNames.join(', ')}`);
79
- continue;
80
- }
81
- const nextTargets = normalizeTargets(node.next);
82
- for (const target of nextTargets) {
83
- if (!nodeIds.has(target)) {
84
- errors.push(`Node '${nodeId}' routes to unknown node '${target}' in 'next'`);
85
- }
86
- }
87
- const errorTargets = normalizeTargets(node.onError);
88
- for (const target of errorTargets) {
89
- if (!nodeIds.has(target)) {
90
- errors.push(`Node '${nodeId}' routes to unknown node '${target}' in 'onError'`);
91
- }
92
- }
93
- if (!node.input)
94
- continue;
95
- const refs = new Set();
96
- collectRefs(node.input, refs);
97
- for (const ref of refs) {
98
- if (ref === 'trigger' || ref === '$item') {
99
- const targetMeta = resolveToolMeta(node.rpcName);
100
- if (targetMeta?.fnMeta?.inputSchemaName) {
101
- const targetSchema = targetMeta.schemas.get(targetMeta.fnMeta.inputSchemaName);
102
- if (targetSchema?.properties) {
103
- for (const [field, fieldValue] of Object.entries(node.input)) {
104
- if (typeof fieldValue === 'object' &&
105
- fieldValue !== null &&
106
- fieldValue.$ref === ref &&
107
- !fieldValue.path) {
108
- const targetType = targetSchema.properties[field]?.type;
109
- if (targetType && targetType !== 'object') {
110
- errors.push(`Node '${nodeId}' input field '${field}' expects type '${targetType}', but references the whole ${ref} object without a path. Use { $ref: "${ref}", path: "${field}" } to extract the field.`);
111
- }
112
- }
113
- }
114
- }
115
- }
116
- continue;
117
- }
118
- if (!nodeIds.has(ref)) {
119
- errors.push(`Node '${nodeId}' references unknown node '${ref}' in input`);
120
- continue;
121
- }
122
- const sourceNode = nodes[ref];
123
- if (!sourceNode?.rpcName)
124
- continue;
125
- const sourceMeta = resolveToolMeta(sourceNode.rpcName);
126
- if (!sourceMeta?.fnMeta?.outputSchemaName)
127
- continue;
128
- const sourceSchema = sourceMeta.schemas.get(sourceMeta.fnMeta.outputSchemaName);
129
- if (!sourceSchema?.properties)
130
- continue;
131
- for (const [field, fieldValue] of Object.entries(node.input)) {
132
- if (typeof fieldValue === 'object' &&
133
- fieldValue !== null &&
134
- fieldValue.$ref === ref &&
135
- fieldValue.path) {
136
- const pathRoot = fieldValue.path.split('.')[0];
137
- if (sourceSchema.properties &&
138
- !(pathRoot in sourceSchema.properties)) {
139
- errors.push(`Node '${nodeId}' input field '${field}' references path '${fieldValue.path}' but node '${ref}' (${sourceNode.rpcName}) output has no property '${pathRoot}'`);
140
- continue;
141
- }
142
- const targetMeta = resolveToolMeta(node.rpcName);
143
- if (!targetMeta?.fnMeta?.inputSchemaName)
144
- continue;
145
- const targetSchema = targetMeta.schemas.get(targetMeta.fnMeta.inputSchemaName);
146
- if (!targetSchema?.properties?.[field])
147
- continue;
148
- const sourceType = sourceSchema.properties[pathRoot]?.type;
149
- const targetType = targetSchema.properties[field].type;
150
- if (sourceType && targetType && sourceType !== targetType) {
151
- errors.push(`Node '${nodeId}' input field '${field}' expects type '${targetType}', but node '${ref}' output field '${pathRoot}' is type '${sourceType}'`);
152
- }
153
- }
154
- }
155
- }
156
- }
157
- return errors;
158
- }
159
17
  export function generateMermaidDiagram(workflowName, nodes, entryNodeIds) {
160
18
  const lines = ['graph TD'];
161
19
  for (const [nodeId, node] of Object.entries(nodes)) {
@@ -2,4 +2,4 @@ export { continueGraph, executeGraphStep, onGraphNodeComplete, runFromMeta, runW
2
2
  export { template } from './template.js';
3
3
  export type { TemplateString } from './template.js';
4
4
  export { pikkuWorkflowGraph, type PikkuWorkflowGraphConfig, type PikkuWorkflowGraphResult, } from './wire-workflow-graph.js';
5
- export { validateWorkflowWiring, computeEntryNodeIds, generateMermaidDiagram, } from './graph-validation.js';
5
+ export { generateMermaidDiagram } from './graph-validation.js';
@@ -1,4 +1,4 @@
1
1
  export { continueGraph, executeGraphStep, onGraphNodeComplete, runFromMeta, runWorkflowGraph, } from './graph-runner.js';
2
2
  export { template } from './template.js';
3
3
  export { pikkuWorkflowGraph, } from './wire-workflow-graph.js';
4
- export { validateWorkflowWiring, computeEntryNodeIds, generateMermaidDiagram, } from './graph-validation.js';
4
+ export { generateMermaidDiagram } from './graph-validation.js';
@@ -10,7 +10,6 @@ export { addWorkflow } from './dsl/workflow-runner.js';
10
10
  export { addFeature, resolveFeatureScenarios } from './feature.js';
11
11
  export { template, type TemplateString } from './graph/template.js';
12
12
  export { pikkuWorkflowGraph, type PikkuWorkflowGraphConfig, type PikkuWorkflowGraphResult, } from './graph/wire-workflow-graph.js';
13
- export { validateWorkflowWiring, computeEntryNodeIds, } from './graph/graph-validation.js';
14
13
  export { pikkuWorkflowWorkerFunc, pikkuWorkflowOrchestratorFunc, pikkuWorkflowSleeperFunc, } from './workflow-queue-workers.js';
15
14
  export type { WorkflowStepInput as WorkflowStepQueueInput, PikkuWorkflowOrchestratorInput, PikkuWorkflowSleeperInput, } from './workflow-queue-workers.js';
16
15
  export type { WorkflowService, WorkflowQueueOptions, WorkflowServiceConfig, WorkflowPlannedStep, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, StepStatus, WorkflowRun, WorkflowRunStatus, StepState, WorkflowRunService, WorkflowRunMirror, CoreWorkflow, CoreFeature, CoreFeatureScenario, FeatureMeta, FeatureMetaEntry, FeaturesMeta, FeaturePlanEntry, PikkuWorkflow, ContextVariable, WorkflowContext, WorkflowsMeta, WorkflowRuntimeMeta, WorkflowsRuntimeMeta, WorkflowStepInput, WorkflowOrchestratorInput, WorkflowSleeperInput, } from './workflow.types.js';
@@ -11,8 +11,6 @@ export { addFeature, resolveFeatureScenarios } from './feature.js';
11
11
  // Graph helpers (template, pikkuWorkflowGraph)
12
12
  export { template } from './graph/template.js';
13
13
  export { pikkuWorkflowGraph, } from './graph/wire-workflow-graph.js';
14
- // Graph validation and dynamic workflow utilities
15
- export { validateWorkflowWiring, computeEntryNodeIds, } from './graph/graph-validation.js';
16
14
  // Queue worker functions (registered by codegen, executed at runtime)
17
15
  export { pikkuWorkflowWorkerFunc, pikkuWorkflowOrchestratorFunc, pikkuWorkflowSleeperFunc, } from './workflow-queue-workers.js';
18
16
  // Narrows the optional halves of the step wire, with a message that says what to do
@@ -154,14 +154,12 @@ export interface WorkflowRunExtension {
154
154
  */
155
155
  onAfterRunFunc(context: RunLifecycleContext, outcome: 'completed' | 'failed' | 'interrupted', failure: unknown): Promise<void>;
156
156
  }
157
- /**
158
- * Abstract workflow state service
159
- * Implementations provide pluggable storage backends (SQLite, PostgreSQL, etc.)
160
- * Combines orchestration and step execution
161
- */
162
157
  export declare abstract class PikkuWorkflowService implements WorkflowService {
163
- private inlineRuns;
164
158
  private runExtension?;
159
+ private runContexts;
160
+ private contextFor;
161
+ /** Drop a run's context once nothing is holding it open. */
162
+ private releaseContext;
165
163
  protected get logger(): import("../../services/logger.js").Logger;
166
164
  protected mirror?: WorkflowRunMirror;
167
165
  protected readonly queueStrategy: 'per-workflow' | 'shared-groups';
@@ -171,7 +169,18 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
171
169
  wireQueues?: boolean;
172
170
  mirror?: WorkflowRunMirror;
173
171
  } & WorkflowQueueOptions);
174
- private safeMirror;
172
+ /**
173
+ * Perform a state write, then shadow it to the mirror.
174
+ *
175
+ * The mirror is an observability sink, never a second source of truth, and
176
+ * both halves of that follow from this one shape: it is only ever told about
177
+ * a write that already landed, and a mirror that is down or throwing cannot
178
+ * fail — or even be seen by — the workflow it is watching.
179
+ *
180
+ * @param write - the authoritative write; its result is what the caller gets
181
+ * @param mirror - shadows the write, given the live mirror and what was written
182
+ */
183
+ private mirrored;
175
184
  /**
176
185
  * Wire the queue-based orchestrator/step/sleeper workers.
177
186
  * Subclasses that orchestrate without queues (e.g. Durable Objects) should
@@ -392,11 +401,6 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
392
401
  graph: any;
393
402
  source: string;
394
403
  } | null>;
395
- abstract getAIGeneratedWorkflows(agentName?: string): Promise<Array<{
396
- workflowName: string;
397
- graphHash: string;
398
- graph: any;
399
- }>>;
400
404
  /**
401
405
  * Resume a paused workflow by triggering the orchestrator
402
406
  * @param runId - Run ID
@@ -480,9 +484,59 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
480
484
  pollIntervalMs?: number;
481
485
  wire?: WorkflowRunWire;
482
486
  }): Promise<any>;
483
- private stepOrdinals;
484
- private stepLineage;
485
- private resetStepOrdinals;
487
+ /**
488
+ * Read a run until it reaches an end state, backing off as it drags on.
489
+ *
490
+ * A fixed interval is wrong at both ends: it makes a workflow that finished
491
+ * in milliseconds wait out the whole interval anyway, and it keeps reading a
492
+ * long-running one at full rate for as long as it lasts. Starting short and
493
+ * growing to `maxIntervalMs` returns quick runs promptly while a slow run's
494
+ * read cost grows logarithmically rather than linearly with its duration.
495
+ */
496
+ protected awaitRunEnd(runId: string, maxIntervalMs: number): Promise<WorkflowRun>;
497
+ /**
498
+ * Wait between two reads of a run.
499
+ *
500
+ * Its own method so the backoff schedule can be asserted on directly. Timing
501
+ * a poll loop by the clock measures the host's scheduler as much as the
502
+ * policy — `setTimeout(40)` routinely returns late on a loaded runner — which
503
+ * makes the obvious test both slow and flaky.
504
+ */
505
+ protected waitBeforeNextRead(ms: number): Promise<void>;
506
+ /**
507
+ * Every step of a run in one read, or `null` if this backend has no bulk read.
508
+ *
509
+ * A replay walks the DSL body from the top, and each step it passes asks for
510
+ * its own row — so a run of N steps costs N reads per replay and O(N^2) over
511
+ * its lifetime. Backends that can answer this in a single query collapse that
512
+ * to one read per replay.
513
+ */
514
+ protected listStepStates(_runId: string): Promise<Array<StepState & {
515
+ stepName: string;
516
+ }> | null>;
517
+ /**
518
+ * Begin a replay pass: fresh ordinal counters, and one read of the steps the
519
+ * run has already taken so the walk back to where it left off is served from
520
+ * memory. Safe because a pass reaches each step key at most once, and the
521
+ * steps it replays past are `succeeded` and therefore immutable.
522
+ */
523
+ private beginReplay;
524
+ private endReplay;
525
+ /**
526
+ * The step row for `stepName`, creating it if the run has not reached it
527
+ * before. Served from the replay snapshot when one is loaded.
528
+ */
529
+ private loadOrCreateStep;
530
+ /**
531
+ * The run's immutable half — which workflow it is, the wire it was started
532
+ * on, its input. `getRun` is otherwise called several times per step for
533
+ * answers that were all fixed at creation, so a replay reads it once and
534
+ * hands the same object to everyone who only needs that half.
535
+ *
536
+ * Anyone who needs `status`, `output`, `error` or `state` must call `getRun`:
537
+ * those move while the run executes, and a cached copy would be a lie.
538
+ */
539
+ private getRunIdentity;
486
540
  /** The step the DSL walk last reached (the predecessor for the next step). */
487
541
  private lastStepName;
488
542
  /**