@pikku/core 0.12.148 → 0.12.151
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/dist/function/function-runner.js +23 -0
- package/dist/function/trace-queue-service.d.ts +26 -0
- package/dist/function/trace-queue-service.js +48 -0
- package/dist/mesh.d.ts +2 -2
- package/dist/mesh.js +3 -3
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/mesh-auth.d.ts +3 -2
- package/dist/middleware/mesh-auth.js +7 -2
- package/dist/middleware/remote-jobs-secret.d.ts +1 -1
- package/dist/middleware/require-origin.d.ts +19 -0
- package/dist/middleware/require-origin.js +36 -2
- package/dist/services/deployment-service.d.ts +1 -1
- package/dist/services/incoming-webhook-service.d.ts +4 -2
- package/dist/services/incoming-webhook-service.js +4 -3
- package/dist/services/queue-webhook-service.js +7 -1
- package/dist/services/remote-jobs.d.ts +6 -1
- package/dist/services/scheduler-service.d.ts +3 -1
- package/dist/services/webhook-service.d.ts +2 -0
- package/dist/services/workflow-service.d.ts +4 -1
- package/dist/testing/index.d.ts +4 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/queue-identity-transport-tests.d.ts +18 -0
- package/dist/testing/queue-identity-transport-tests.js +162 -0
- package/dist/testing/queue-trace-conformance.d.ts +26 -0
- package/dist/testing/queue-trace-conformance.js +259 -0
- package/dist/testing/service-tests/channel-store-tests.js +11 -0
- package/dist/testing/service-tests/queued-workflow-harness.d.ts +18 -1
- package/dist/testing/service-tests/queued-workflow-harness.js +19 -3
- package/dist/testing/service-tests/workflow-service-tests.js +12 -0
- package/dist/testing/trace-matrix/assertions.d.ts +3 -0
- package/dist/testing/trace-matrix/assertions.js +190 -0
- package/dist/testing/trace-matrix/chain-harness.d.ts +119 -0
- package/dist/testing/trace-matrix/chain-harness.js +777 -0
- package/dist/testing/trace-matrix/dimensions.d.ts +55 -0
- package/dist/testing/trace-matrix/dimensions.js +305 -0
- package/dist/testing/trace-matrix/pairwise.d.ts +20 -0
- package/dist/testing/trace-matrix/pairwise.js +75 -0
- package/dist/testing/trace-matrix/report.d.ts +1 -0
- package/dist/testing/trace-matrix/report.js +16 -0
- package/dist/testing/trace-matrix/transports.d.ts +19 -0
- package/dist/testing/trace-matrix/transports.js +27 -0
- package/dist/testing/trace-matrix/workflow-cases.d.ts +24 -0
- package/dist/testing/trace-matrix/workflow-cases.js +147 -0
- package/dist/trace.d.ts +66 -0
- package/dist/trace.js +97 -0
- package/dist/types/core.types.d.ts +6 -2
- package/dist/wirings/addon/addon-reach.d.ts +3 -0
- package/dist/wirings/addon/addon-reach.js +9 -0
- package/dist/wirings/addon/define-plugin.d.ts +16 -0
- package/dist/wirings/addon/wire-addon.js +5 -0
- package/dist/wirings/addon/wire-remote-addon.d.ts +11 -0
- package/dist/wirings/agent/agent-finalize.d.ts +2 -0
- package/dist/wirings/agent/agent-finalize.js +1 -1
- package/dist/wirings/agent/agent-prepare.d.ts +12 -2
- package/dist/wirings/agent/agent-prepare.js +38 -14
- package/dist/wirings/agent/agent-rpc.js +2 -1
- package/dist/wirings/agent/agent-runner.js +5 -6
- package/dist/wirings/agent/agent-stream.js +6 -6
- package/dist/wirings/agent/voice-input.d.ts +1 -1
- package/dist/wirings/agent/voice-output.d.ts +1 -1
- package/dist/wirings/agent-scorer/agent-scorer-live.d.ts +4 -2
- package/dist/wirings/agent-scorer/agent-scorer-live.js +2 -2
- package/dist/wirings/channel/channel-common.d.ts +4 -1
- package/dist/wirings/channel/channel-common.js +3 -1
- package/dist/wirings/channel/channel-handler.js +2 -0
- package/dist/wirings/channel/channel-host-rpc.d.ts +1 -1
- package/dist/wirings/channel/channel-host-rpc.js +2 -2
- package/dist/wirings/channel/channel-rpc-responder.d.ts +7 -1
- package/dist/wirings/channel/channel-rpc-responder.js +2 -2
- package/dist/wirings/channel/channel-rpc-service.d.ts +1 -1
- package/dist/wirings/channel/channel-rpc-service.js +2 -2
- package/dist/wirings/channel/channel-rpc.types.d.ts +5 -2
- package/dist/wirings/channel/channel-rpc.types.js +1 -1
- package/dist/wirings/channel/channel-store.d.ts +4 -2
- package/dist/wirings/channel/channel-store.js +2 -2
- package/dist/wirings/channel/channel.types.d.ts +3 -0
- package/dist/wirings/channel/local/local-channel-runner.js +16 -5
- package/dist/wirings/channel/pikku-abstract-channel-handler.d.ts +2 -0
- package/dist/wirings/channel/pikku-abstract-channel-handler.js +3 -1
- package/dist/wirings/channel/serverless/serverless-channel-runner.js +27 -4
- package/dist/wirings/gateway/gateway-runner.js +3 -1
- package/dist/wirings/http/http-runner.d.ts +4 -4
- package/dist/wirings/http/http-runner.js +14 -18
- package/dist/wirings/http/http.types.d.ts +3 -3
- package/dist/wirings/mcp/mcp-runner.js +15 -5
- package/dist/wirings/queue/in-memory-queue-service.d.ts +1 -1
- package/dist/wirings/queue/in-memory-queue-service.js +8 -2
- package/dist/wirings/queue/index.d.ts +1 -0
- package/dist/wirings/queue/index.js +1 -0
- package/dist/wirings/queue/queue-runner.d.ts +3 -4
- package/dist/wirings/queue/queue-runner.js +8 -7
- package/dist/wirings/queue/queue-trace.d.ts +18 -0
- package/dist/wirings/queue/queue-trace.js +42 -0
- package/dist/wirings/queue/queue.types.d.ts +7 -0
- package/dist/wirings/queue/remote-queue-job.d.ts +1 -1
- package/dist/wirings/queue/remote-queue-job.js +5 -3
- package/dist/wirings/rpc/rpc-runner.d.ts +7 -1
- package/dist/wirings/rpc/rpc-runner.js +43 -21
- package/dist/wirings/rpc/rpc-types.d.ts +2 -0
- package/dist/wirings/scheduler/remote-scheduled-job.d.ts +1 -1
- package/dist/wirings/scheduler/remote-scheduled-job.js +2 -2
- package/dist/wirings/scheduler/scheduler-runner.d.ts +4 -4
- package/dist/wirings/scheduler/scheduler-runner.js +7 -6
- package/dist/wirings/trigger/pikku-trigger-service.js +7 -4
- package/dist/wirings/trigger/webhook-source-runner.d.ts +7 -1
- package/dist/wirings/trigger/webhook-source-runner.js +11 -3
- package/dist/wirings/workflow/graph/graph-runner.d.ts +1 -1
- package/dist/wirings/workflow/graph/graph-runner.js +49 -30
- package/dist/wirings/workflow/index.d.ts +3 -1
- package/dist/wirings/workflow/index.js +1 -0
- package/dist/wirings/workflow/pikku-scenario-service.js +2 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +37 -40
- package/dist/wirings/workflow/pikku-workflow-service.js +122 -133
- package/dist/wirings/workflow/workflow-child-step.d.ts +2 -2
- package/dist/wirings/workflow/workflow-child-step.js +11 -4
- package/dist/wirings/workflow/workflow-compensation.d.ts +6 -0
- package/dist/wirings/workflow/workflow-compensation.js +15 -1
- package/dist/wirings/workflow/workflow-constants.d.ts +13 -0
- package/dist/wirings/workflow/workflow-constants.js +13 -0
- package/dist/wirings/workflow/workflow-dsl-pass.d.ts +2 -0
- package/dist/wirings/workflow/workflow-dsl-pass.js +27 -11
- package/dist/wirings/workflow/workflow-recovery.d.ts +131 -3
- package/dist/wirings/workflow/workflow-recovery.js +233 -7
- package/dist/wirings/workflow/workflow-run-engine.types.d.ts +1 -0
- package/dist/wirings/workflow/workflow-run-mirror.d.ts +4 -0
- package/dist/wirings/workflow/workflow-run-mirror.js +16 -0
- package/dist/wirings/workflow/workflow-run-status.d.ts +2 -0
- package/dist/wirings/workflow/workflow-run-status.js +13 -0
- package/dist/wirings/workflow/workflow-run-trace.d.ts +22 -0
- package/dist/wirings/workflow/workflow-run-trace.js +24 -0
- package/dist/wirings/workflow/workflow-step-claim.d.ts +18 -0
- package/dist/wirings/workflow/workflow-step-claim.js +47 -0
- package/dist/wirings/workflow/workflow-step-retry.d.ts +6 -2
- package/dist/wirings/workflow/workflow-step-retry.js +8 -3
- package/dist/wirings/workflow/workflow-wire.d.ts +12 -0
- package/dist/wirings/workflow/workflow-wire.js +33 -0
- package/dist/wirings/workflow/workflow.types.d.ts +16 -0
- package/package.json +2 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { QueueService } from '../wirings/queue/queue.types.js';
|
|
2
|
+
/** A real queue adapter, as this suite needs it. */
|
|
3
|
+
export type QueueTraceAdapter = {
|
|
4
|
+
service: QueueService;
|
|
5
|
+
/** Registers workers for what `registerQueue` declared. */
|
|
6
|
+
startWorkers: () => Promise<void>;
|
|
7
|
+
/** Stops workers and closes every connection. */
|
|
8
|
+
stop: () => Promise<void>;
|
|
9
|
+
/** Publishes `payload` as it stands, as a producer older than tracing did. */
|
|
10
|
+
publishRaw: (queueName: string, payload: unknown) => Promise<void>;
|
|
11
|
+
/** The trace travels in the payload, so a worker unwraps what it is given. */
|
|
12
|
+
envelope?: boolean;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Runs each outcome × inbound × timing as one job through a real adapter, in one
|
|
16
|
+
* batch so it stays fast against a broker, then jobs in the formats of older and
|
|
17
|
+
* newer producers. Asserts on what the worker saw, so a wire format that loses
|
|
18
|
+
* or changes the trace fails here on the adapter that has it.
|
|
19
|
+
*
|
|
20
|
+
* @param name - The adapter, for the test names.
|
|
21
|
+
* @param create - Connects the adapter.
|
|
22
|
+
* @param options.settleMs - How long a batch may take, retries included.
|
|
23
|
+
*/
|
|
24
|
+
export declare const defineQueueTraceConformance: (name: string, create: () => Promise<QueueTraceAdapter>, { settleMs }?: {
|
|
25
|
+
settleMs?: number;
|
|
26
|
+
}) => void;
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { after, before, describe, test } from 'node:test';
|
|
3
|
+
import { ConflictError } from '../errors/errors.js';
|
|
4
|
+
import { AbandonedError } from '../function/abort-scope.js';
|
|
5
|
+
import { addFunction } from '../function/function-runner.js';
|
|
6
|
+
import { pikkuState, resetPikkuState } from '../pikku-state.js';
|
|
7
|
+
import { parseTraceparent } from '../trace.js';
|
|
8
|
+
import { SignedQueueService } from '../wirings/queue/signed-queue-service.js';
|
|
9
|
+
const OUTCOMES = [
|
|
10
|
+
'success',
|
|
11
|
+
'throw-pikku-error',
|
|
12
|
+
'throw-plain-error',
|
|
13
|
+
'job-failed',
|
|
14
|
+
'job-discarded',
|
|
15
|
+
'abandoned',
|
|
16
|
+
];
|
|
17
|
+
const INBOUNDS = ['none', 'valid', 'invalid', 'forged'];
|
|
18
|
+
const TIMINGS = [
|
|
19
|
+
'immediate',
|
|
20
|
+
'delayed',
|
|
21
|
+
'retried',
|
|
22
|
+
'duplicate',
|
|
23
|
+
'concurrent',
|
|
24
|
+
];
|
|
25
|
+
const hex = (length) => Array.from({ length }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
|
26
|
+
const inboundFor = (inbound) => {
|
|
27
|
+
switch (inbound) {
|
|
28
|
+
case 'none':
|
|
29
|
+
return undefined;
|
|
30
|
+
case 'valid':
|
|
31
|
+
return `00-1${hex(31)}-2${hex(15)}-01`;
|
|
32
|
+
case 'invalid':
|
|
33
|
+
return `not-a-traceparent-${hex(4)}`;
|
|
34
|
+
case 'forged':
|
|
35
|
+
return `00-${'0'.repeat(32)}-${hex(16)}-01`;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const expectedAttempts = (outcome, timing) => {
|
|
39
|
+
if (timing === 'duplicate')
|
|
40
|
+
return 2;
|
|
41
|
+
if (timing !== 'retried')
|
|
42
|
+
return 1;
|
|
43
|
+
return outcome === 'job-discarded' ? 1 : 2;
|
|
44
|
+
};
|
|
45
|
+
const SEEDED_SECRETS = {
|
|
46
|
+
getSecret: async () => ({ reveal: () => 'x'.repeat(64) }),
|
|
47
|
+
hasSecret: async () => true,
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Runs each outcome × inbound × timing as one job through a real adapter, in one
|
|
51
|
+
* batch so it stays fast against a broker, then jobs in the formats of older and
|
|
52
|
+
* newer producers. Asserts on what the worker saw, so a wire format that loses
|
|
53
|
+
* or changes the trace fails here on the adapter that has it.
|
|
54
|
+
*
|
|
55
|
+
* @param name - The adapter, for the test names.
|
|
56
|
+
* @param create - Connects the adapter.
|
|
57
|
+
* @param options.settleMs - How long a batch may take, retries included.
|
|
58
|
+
*/
|
|
59
|
+
export const defineQueueTraceConformance = (name, create, { settleMs = 30_000 } = {}) => {
|
|
60
|
+
describe(`${name}: a job keeps its trace`, () => {
|
|
61
|
+
const queue = `trace_conformance_${hex(8)}`;
|
|
62
|
+
const attempts = new Map();
|
|
63
|
+
const logs = [];
|
|
64
|
+
let adapter;
|
|
65
|
+
const sent = new Map();
|
|
66
|
+
const logger = (scope) => {
|
|
67
|
+
const write = (level) => () => void logs.push({ level, scope });
|
|
68
|
+
return {
|
|
69
|
+
info: write('info'),
|
|
70
|
+
warn: write('warn'),
|
|
71
|
+
debug: write('debug'),
|
|
72
|
+
error: write('error'),
|
|
73
|
+
scope: (traceId) => logger(traceId),
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
const worker = async (_services, data, wire) => {
|
|
77
|
+
const key = String(data?.id ?? data?.data?.id ?? data);
|
|
78
|
+
const seen = attempts.get(key) ?? [];
|
|
79
|
+
seen.push({ trace: wire.trace, data });
|
|
80
|
+
attempts.set(key, seen);
|
|
81
|
+
if (data?.failFirst && seen.length === 1 && data.outcome === 'success') {
|
|
82
|
+
throw new Error('flaky');
|
|
83
|
+
}
|
|
84
|
+
switch (data?.outcome) {
|
|
85
|
+
case 'throw-pikku-error':
|
|
86
|
+
throw new ConflictError('refused');
|
|
87
|
+
case 'throw-plain-error':
|
|
88
|
+
throw new Error('boom');
|
|
89
|
+
case 'job-failed':
|
|
90
|
+
return wire.queue.fail('no');
|
|
91
|
+
case 'job-discarded':
|
|
92
|
+
return wire.queue.discard('no');
|
|
93
|
+
case 'abandoned':
|
|
94
|
+
throw new AbandonedError('the caller left');
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
before(async () => {
|
|
98
|
+
resetPikkuState();
|
|
99
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
100
|
+
logger: logger(),
|
|
101
|
+
config: {},
|
|
102
|
+
secrets: SEEDED_SECRETS,
|
|
103
|
+
});
|
|
104
|
+
const funcId = `queue_${queue}`;
|
|
105
|
+
pikkuState(null, 'queue', 'meta')[queue] = {
|
|
106
|
+
pikkuFuncId: funcId,
|
|
107
|
+
name: queue,
|
|
108
|
+
};
|
|
109
|
+
pikkuState(null, 'function', 'meta')[funcId] = {
|
|
110
|
+
pikkuFuncId: funcId,
|
|
111
|
+
inputSchemaName: null,
|
|
112
|
+
outputSchemaName: null,
|
|
113
|
+
sessionless: true,
|
|
114
|
+
};
|
|
115
|
+
addFunction(funcId, { func: worker, auth: false });
|
|
116
|
+
pikkuState(null, 'queue', 'registrations').set(queue, {
|
|
117
|
+
name: queue,
|
|
118
|
+
func: { func: worker, auth: false },
|
|
119
|
+
});
|
|
120
|
+
adapter = await create();
|
|
121
|
+
await adapter.startWorkers();
|
|
122
|
+
const adds = [];
|
|
123
|
+
for (const outcome of OUTCOMES) {
|
|
124
|
+
for (const inbound of INBOUNDS) {
|
|
125
|
+
for (const timing of TIMINGS) {
|
|
126
|
+
for (const copy of timing === 'concurrent' ? [0, 1] : [0]) {
|
|
127
|
+
const id = hex(16);
|
|
128
|
+
const label = `${outcome} / ${inbound} / ${timing}${copy ? ' #2' : ''}`;
|
|
129
|
+
const traceparent = inboundFor(inbound);
|
|
130
|
+
const job = {
|
|
131
|
+
id,
|
|
132
|
+
outcome,
|
|
133
|
+
failFirst: timing === 'retried',
|
|
134
|
+
};
|
|
135
|
+
sent.set(id, {
|
|
136
|
+
label,
|
|
137
|
+
traceparent,
|
|
138
|
+
inbound,
|
|
139
|
+
expected: expectedAttempts(outcome, timing),
|
|
140
|
+
});
|
|
141
|
+
const options = {
|
|
142
|
+
traceparent,
|
|
143
|
+
attempts: timing === 'retried' ? 2 : 1,
|
|
144
|
+
backoff: { type: 'fixed', delay: 10 },
|
|
145
|
+
...(timing === 'delayed' ? { delay: 50 } : {}),
|
|
146
|
+
};
|
|
147
|
+
adds.push(adapter.service.add(queue, job, options));
|
|
148
|
+
if (timing === 'duplicate') {
|
|
149
|
+
adds.push(adapter.service.add(queue, job, options));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
await Promise.all(adds);
|
|
156
|
+
const deadline = Date.now() + settleMs;
|
|
157
|
+
const pending = () => [...sent].filter(([id, { expected }]) => (attempts.get(id)?.length ?? 0) < expected);
|
|
158
|
+
while (pending().length && Date.now() < deadline) {
|
|
159
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
after(async () => {
|
|
163
|
+
await adapter.stop();
|
|
164
|
+
});
|
|
165
|
+
test('every job ran as often as it should', () => {
|
|
166
|
+
const short = [...sent]
|
|
167
|
+
.filter(([id, { expected }]) => (attempts.get(id)?.length ?? 0) !== expected)
|
|
168
|
+
.map(([id, { label }]) => `${label}: ${attempts.get(id)?.length ?? 0}`);
|
|
169
|
+
assert.deepEqual(short, []);
|
|
170
|
+
});
|
|
171
|
+
for (const outcome of OUTCOMES) {
|
|
172
|
+
for (const inbound of INBOUNDS) {
|
|
173
|
+
for (const timing of TIMINGS) {
|
|
174
|
+
test(`${outcome} / ${inbound} / ${timing}`, () => {
|
|
175
|
+
const jobs = [...sent].filter(([, { label }]) => label.startsWith(`${outcome} / ${inbound} / ${timing}`));
|
|
176
|
+
assert.ok(jobs.length > 0);
|
|
177
|
+
for (const [id, { traceparent }] of jobs) {
|
|
178
|
+
const seen = (attempts.get(id) ?? []).map(({ trace }) => trace);
|
|
179
|
+
assert.ok(seen.length > 0, 'the job never ran');
|
|
180
|
+
const carried = parseTraceparent(traceparent);
|
|
181
|
+
for (const trace of seen) {
|
|
182
|
+
assert.match(trace.traceId, /^[0-9a-f]{32}$/);
|
|
183
|
+
assert.match(trace.spanId, /^[0-9a-f]{16}$/);
|
|
184
|
+
if (carried) {
|
|
185
|
+
assert.equal(trace.traceId, carried.traceId, 'trace changed');
|
|
186
|
+
assert.equal(trace.parentSpanId, carried.parentSpanId);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
assert.equal(trace.parentSpanId, undefined, 'not a new trace');
|
|
190
|
+
if (traceparent) {
|
|
191
|
+
assert.ok(!traceparent.includes(trace.traceId), 'adopted');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const spans = seen.map(({ spanId }) => spanId);
|
|
196
|
+
assert.equal(new Set(spans).size, spans.length, 'a span repeats');
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
test('error logs from running a job carry its trace', () => {
|
|
203
|
+
const traces = new Set([...attempts.values()].flat().map(({ trace }) => trace.traceId));
|
|
204
|
+
const failures = logs.filter(({ level, scope }) => level === 'error' && scope);
|
|
205
|
+
assert.ok(failures.length > 0, 'no failure was logged');
|
|
206
|
+
for (const { scope } of failures) {
|
|
207
|
+
assert.ok(traces.has(scope), `a log carries a stray trace ${scope}`);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
describe('jobs from producers of every age', () => {
|
|
211
|
+
const run = async (id, publish) => {
|
|
212
|
+
await publish();
|
|
213
|
+
const deadline = Date.now() + settleMs;
|
|
214
|
+
while (!attempts.get(id)?.length && Date.now() < deadline) {
|
|
215
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
216
|
+
}
|
|
217
|
+
const [attempt] = attempts.get(id) ?? [];
|
|
218
|
+
assert.ok(attempt, 'the job never ran');
|
|
219
|
+
return attempt;
|
|
220
|
+
};
|
|
221
|
+
test('old format: no trace at all', async () => {
|
|
222
|
+
const data = { id: hex(16), outcome: 'success' };
|
|
223
|
+
const { trace, data: seen } = await run(data.id, () => adapter.publishRaw(queue, data));
|
|
224
|
+
assert.deepEqual(seen, data);
|
|
225
|
+
assert.equal(trace.parentSpanId, undefined);
|
|
226
|
+
});
|
|
227
|
+
test('new format: a trace alongside a signed identity', async () => {
|
|
228
|
+
const data = { id: hex(16), outcome: 'success' };
|
|
229
|
+
const traceparent = inboundFor('valid');
|
|
230
|
+
const signed = new SignedQueueService(adapter.service, SEEDED_SECRETS, logger());
|
|
231
|
+
const { trace, data: seen } = await run(data.id, () => signed.add(queue, data, {
|
|
232
|
+
traceparent,
|
|
233
|
+
pikkuUserId: 'user-1',
|
|
234
|
+
}));
|
|
235
|
+
assert.deepEqual(seen, data);
|
|
236
|
+
assert.equal(trace.traceId, parseTraceparent(traceparent).traceId);
|
|
237
|
+
});
|
|
238
|
+
test('data shaped like the envelope, with and without a trace', async () => {
|
|
239
|
+
for (const traceparent of [undefined, inboundFor('valid')]) {
|
|
240
|
+
const id = hex(16);
|
|
241
|
+
const shaped = { __pikkuTraceparent: 'mine', data: { id } };
|
|
242
|
+
const key = `shaped-${id}`;
|
|
243
|
+
const { data } = await run(key, () => adapter.service.add(queue, { ...shaped, id: key }, {
|
|
244
|
+
traceparent,
|
|
245
|
+
}));
|
|
246
|
+
assert.deepEqual(data, { ...shaped, id: key });
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
test('a poison envelope starts a new trace and keeps the data', async () => {
|
|
250
|
+
const id = hex(16);
|
|
251
|
+
const inner = { id, outcome: 'success' };
|
|
252
|
+
const poison = { __pikkuTraceparent: 'garbage', data: inner };
|
|
253
|
+
const { trace, data } = await run(id, () => adapter.publishRaw(queue, poison));
|
|
254
|
+
assert.equal(trace.parentSpanId, undefined);
|
|
255
|
+
assert.deepEqual(data, adapter.envelope ? inner : poison);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
};
|
|
@@ -20,6 +20,17 @@ export const defineChannelStoreTests = (name, channelStore) => {
|
|
|
20
20
|
assert.deepEqual(result.openingData, { foo: 'bar' });
|
|
21
21
|
assert.equal(result.pikkuUserId, undefined);
|
|
22
22
|
});
|
|
23
|
+
test('keeps the traceparent the channel was added with', async () => {
|
|
24
|
+
const traceparent = `00-${'a'.repeat(32)}-${'b'.repeat(16)}-01`;
|
|
25
|
+
await store.addChannel({
|
|
26
|
+
channelId: 'ch-traced',
|
|
27
|
+
channelName: 'test-channel',
|
|
28
|
+
traceparent,
|
|
29
|
+
});
|
|
30
|
+
assert.equal((await store.getChannel('ch-traced')).traceparent, traceparent);
|
|
31
|
+
assert.equal((await store.getChannel('ch-1')).traceparent, undefined);
|
|
32
|
+
await store.removeChannels(['ch-traced']);
|
|
33
|
+
});
|
|
23
34
|
test('setPikkuUserId', async () => {
|
|
24
35
|
await store.setPikkuUserId('ch-1', 'user-1');
|
|
25
36
|
const result = await store.getChannel('ch-1');
|
|
@@ -32,11 +32,23 @@ export declare class QueuedWorkflowHarness {
|
|
|
32
32
|
queue: string;
|
|
33
33
|
step?: string;
|
|
34
34
|
}>;
|
|
35
|
+
/** The `traceparent` of every job added, in order. */
|
|
36
|
+
jobTraceparents: Array<string | undefined>;
|
|
37
|
+
/** The wire trace of every step and compensation invoked, in order. */
|
|
38
|
+
traces: Array<{
|
|
39
|
+
rpc: string;
|
|
40
|
+
trace: any;
|
|
41
|
+
}>;
|
|
35
42
|
ws: PikkuWorkflowService;
|
|
36
43
|
/** Return true to drop the job instead of running it, simulating a worker that never ran. */
|
|
37
44
|
dropWhen?: (job: Job) => boolean;
|
|
38
45
|
/** Make `queue.add` throw for a job, simulating the broker being unreachable. */
|
|
39
46
|
rejectAddWhen?: (queue: string, data: any) => boolean;
|
|
47
|
+
/** Carries a job in an adapter's wire format, as it would reach a worker. */
|
|
48
|
+
transport?: (data: any, options?: any) => {
|
|
49
|
+
data: any;
|
|
50
|
+
traceparent?: string;
|
|
51
|
+
};
|
|
40
52
|
constructor(service: PikkuWorkflowService);
|
|
41
53
|
readonly rpc: {
|
|
42
54
|
rpcWithWire: (rpcName: string, data: any, wire: any) => Promise<any>;
|
|
@@ -44,7 +56,12 @@ export declare class QueuedWorkflowHarness {
|
|
|
44
56
|
register(name: string, handler: Handler): void;
|
|
45
57
|
defineDsl(name: string, body: (workflow: any, input: any) => Promise<any>): void;
|
|
46
58
|
defineGraph(name: string, entry: string, nodes: Record<string, any>): void;
|
|
47
|
-
start(name: string, input?: any
|
|
59
|
+
start(name: string, input?: any, wire?: {
|
|
60
|
+
type: string;
|
|
61
|
+
traceparent?: string;
|
|
62
|
+
}, options?: {
|
|
63
|
+
inline?: boolean;
|
|
64
|
+
}): Promise<string>;
|
|
48
65
|
/** Run jobs until the queue is empty. */
|
|
49
66
|
pump(limit?: number): Promise<void>;
|
|
50
67
|
run(runId: string): Promise<import("../../wirings/workflow/workflow.types.js").WorkflowRun>;
|
|
@@ -12,11 +12,17 @@ export class QueuedWorkflowHarness {
|
|
|
12
12
|
recovering = [];
|
|
13
13
|
queue = [];
|
|
14
14
|
jobsRun = [];
|
|
15
|
+
/** The `traceparent` of every job added, in order. */
|
|
16
|
+
jobTraceparents = [];
|
|
17
|
+
/** The wire trace of every step and compensation invoked, in order. */
|
|
18
|
+
traces = [];
|
|
15
19
|
ws;
|
|
16
20
|
/** Return true to drop the job instead of running it, simulating a worker that never ran. */
|
|
17
21
|
dropWhen;
|
|
18
22
|
/** Make `queue.add` throw for a job, simulating the broker being unreachable. */
|
|
19
23
|
rejectAddWhen;
|
|
24
|
+
/** Carries a job in an adapter's wire format, as it would reach a worker. */
|
|
25
|
+
transport;
|
|
20
26
|
constructor(service) {
|
|
21
27
|
resetPikkuState();
|
|
22
28
|
this.ws = service;
|
|
@@ -25,7 +31,16 @@ export class QueuedWorkflowHarness {
|
|
|
25
31
|
if (this.rejectAddWhen?.(queue, data)) {
|
|
26
32
|
throw new Error('broker unreachable');
|
|
27
33
|
}
|
|
28
|
-
this.
|
|
34
|
+
const carried = this.transport?.(data, options) ?? {
|
|
35
|
+
data,
|
|
36
|
+
traceparent: options?.traceparent,
|
|
37
|
+
};
|
|
38
|
+
this.jobTraceparents.push(carried.traceparent);
|
|
39
|
+
this.queue.push({
|
|
40
|
+
queue,
|
|
41
|
+
data: carried.data,
|
|
42
|
+
attempts: options?.attempts ?? 1,
|
|
43
|
+
});
|
|
29
44
|
},
|
|
30
45
|
};
|
|
31
46
|
pikkuState(null, 'package', 'singletonServices', {
|
|
@@ -36,6 +51,7 @@ export class QueuedWorkflowHarness {
|
|
|
36
51
|
}
|
|
37
52
|
rpc = {
|
|
38
53
|
rpcWithWire: async (rpcName, data, wire) => {
|
|
54
|
+
this.traces.push({ rpc: rpcName, trace: wire.trace });
|
|
39
55
|
const isCompensation = rpcName.endsWith(':compensate');
|
|
40
56
|
const base = isCompensation
|
|
41
57
|
? rpcName.slice(0, -':compensate'.length)
|
|
@@ -97,8 +113,8 @@ export class QueuedWorkflowHarness {
|
|
|
97
113
|
])),
|
|
98
114
|
};
|
|
99
115
|
}
|
|
100
|
-
async start(name, input = {}) {
|
|
101
|
-
const { runId } = await this.ws.startWorkflow(name, input,
|
|
116
|
+
async start(name, input = {}, wire = { type: 'test' }, options) {
|
|
117
|
+
const { runId } = await this.ws.startWorkflow(name, input, wire, this.rpc, options);
|
|
102
118
|
await this.pump();
|
|
103
119
|
return runId;
|
|
104
120
|
}
|
|
@@ -19,6 +19,18 @@ export const defineWorkflowServiceTests = (name, workflowService) => {
|
|
|
19
19
|
assert.deepEqual(run.input, { key: 'value' });
|
|
20
20
|
assert.equal(run.graphHash, 'hash-1');
|
|
21
21
|
});
|
|
22
|
+
test('a run keeps the traceparent it was created with', async () => {
|
|
23
|
+
const traceparent = `00-${'a'.repeat(32)}-${'b'.repeat(16)}-01`;
|
|
24
|
+
const runId = await service.createRun('traced-workflow', {}, false, 'hash-trace', { ...wire, traceparent });
|
|
25
|
+
const run = await service.getRun(runId);
|
|
26
|
+
assert.equal(run?.wire?.traceparent, traceparent);
|
|
27
|
+
assert.equal(run?.wire?.type, wire.type);
|
|
28
|
+
});
|
|
29
|
+
test('a run created without a trace is given one', async () => {
|
|
30
|
+
const runId = await service.createRun('untraced-workflow', {}, false, 'hash-untraced', wire);
|
|
31
|
+
const run = await service.getRun(runId);
|
|
32
|
+
assert.match(run?.wire?.traceparent ?? '', /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
|
|
33
|
+
});
|
|
22
34
|
test('updateRunStatus', async () => {
|
|
23
35
|
const runId = await service.createRun('status-workflow', {}, false, 'hash-2', wire);
|
|
24
36
|
await service.updateRunStatus(runId, 'completed', { result: 'done' });
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { parseTraceparent } from '../../trace.js';
|
|
3
|
+
import { inboundContinues, } from './chain-harness.js';
|
|
4
|
+
import { outcomeError } from './workflow-cases.js';
|
|
5
|
+
const TRACE_ID = /^[0-9a-f]{32}$/;
|
|
6
|
+
const SPAN_ID = /^[0-9a-f]{16}$/;
|
|
7
|
+
const assertWellFormed = (trace, label) => {
|
|
8
|
+
assert.match(trace.traceId, TRACE_ID, label);
|
|
9
|
+
assert.match(trace.spanId, SPAN_ID, label);
|
|
10
|
+
};
|
|
11
|
+
const assertUniqueSpans = (traces, label) => {
|
|
12
|
+
const spans = traces.map((trace) => trace.spanId);
|
|
13
|
+
assert.equal(new Set(spans).size, spans.length, `${label}: spans repeat`);
|
|
14
|
+
};
|
|
15
|
+
/** Steps and compensations of one run, from the harness's record. */
|
|
16
|
+
const stepTraces = (chain, { index }) => chain.h.traces
|
|
17
|
+
.filter(({ rpc }) => new RegExp(`-${index}(:compensate)?$`).test(rpc))
|
|
18
|
+
.map(({ trace }) => trace);
|
|
19
|
+
const assertEntry = (chain, instance) => {
|
|
20
|
+
const { start } = chain.combo;
|
|
21
|
+
const inbound = chain.inbound;
|
|
22
|
+
const sent = parseTraceparent(instance.sent);
|
|
23
|
+
const continues = inboundContinues(start, inbound) && !chain.meshGuardsEntry;
|
|
24
|
+
for (const entry of instance.entries) {
|
|
25
|
+
assertWellFormed(entry, 'entry');
|
|
26
|
+
if (continues) {
|
|
27
|
+
assert.equal(entry.traceId, sent.traceId, 'the trace was not continued');
|
|
28
|
+
// Parsing names the sender's span as the parent of what comes next.
|
|
29
|
+
const parent = instance.caller?.spanId ?? sent.parentSpanId;
|
|
30
|
+
assert.equal(entry.parentSpanId, parent, 'wrong parent span');
|
|
31
|
+
assert.notEqual(entry.spanId, parent, 'no new span');
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
if (sent) {
|
|
35
|
+
assert.notEqual(entry.traceId, sent.traceId, 'adopted an untrusted id');
|
|
36
|
+
}
|
|
37
|
+
// These hold a root span of their own and call the function from it.
|
|
38
|
+
if (!['channel', 'agent-tool', 'trigger'].includes(start)) {
|
|
39
|
+
assert.equal(entry.parentSpanId, undefined, 'a root has no parent');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
assertUniqueSpans(instance.entries, 'entry');
|
|
44
|
+
// A job with no valid trace has none to keep: each delivery starts one, as
|
|
45
|
+
// `runQueueJob` documents. A trace a job does carry survives every delivery.
|
|
46
|
+
if (continues) {
|
|
47
|
+
const ids = new Set(instance.entries.map((entry) => entry.traceId));
|
|
48
|
+
assert.ok(ids.size <= 1, 'redelivery changed the trace');
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const assertHops = (chain, instance) => {
|
|
52
|
+
const entries = instance.entries;
|
|
53
|
+
const parents = new Map(entries.map((entry) => [entry.spanId, entry]));
|
|
54
|
+
for (const relay of instance.relays) {
|
|
55
|
+
const parent = parents.get(relay.parentSpanId);
|
|
56
|
+
assert.ok(parent, `a relay hangs off ${relay.parentSpanId}`);
|
|
57
|
+
parents.set(relay.spanId, parent);
|
|
58
|
+
}
|
|
59
|
+
for (const target of instance.targets) {
|
|
60
|
+
assertWellFormed(target, 'target');
|
|
61
|
+
const parent = parents.get(target.parentSpanId);
|
|
62
|
+
assert.ok(parent, `wrong parent: ${target.parentSpanId} is none of ${[...parents.keys()]}`);
|
|
63
|
+
assert.equal(target.traceId, parent.traceId, 'the hop changed the trace');
|
|
64
|
+
assert.ok(!parents.has(target.spanId), 'the hop kept the span');
|
|
65
|
+
}
|
|
66
|
+
assertUniqueSpans(instance.targets, 'target');
|
|
67
|
+
for (const outgoing of instance.outgoing) {
|
|
68
|
+
const parsed = parseTraceparent(outgoing);
|
|
69
|
+
assert.ok(parsed, `invalid outgoing traceparent ${outgoing}`);
|
|
70
|
+
assert.ok(entries.some((entry) => entry.traceId === parsed.traceId), 'the wire carries another trace');
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const assertRun = async (chain, instance) => {
|
|
74
|
+
if (!instance.runId)
|
|
75
|
+
return;
|
|
76
|
+
const run = await chain.h.run(instance.runId);
|
|
77
|
+
const stored = parseTraceparent(run.wire.traceparent);
|
|
78
|
+
assert.ok(stored, 'the run lost its stored trace');
|
|
79
|
+
// A redelivered entry starts a run per delivery; each hangs off its own.
|
|
80
|
+
const starter = (span) => instance.entries.find((entry) => entry.spanId === span);
|
|
81
|
+
const entry = starter(stored.parentSpanId);
|
|
82
|
+
assert.ok(entry, 'the run stored another span');
|
|
83
|
+
assert.equal(stored.traceId, entry.traceId, 'the run stored another trace');
|
|
84
|
+
const steps = stepTraces(chain, instance);
|
|
85
|
+
for (const step of steps) {
|
|
86
|
+
assert.ok(step, 'a step ran with no trace');
|
|
87
|
+
const parent = starter(step.parentSpanId);
|
|
88
|
+
assert.ok(parent, 'steps hang off the start');
|
|
89
|
+
assert.equal(step.traceId, parent.traceId, 'a step left the trace');
|
|
90
|
+
}
|
|
91
|
+
assertUniqueSpans(steps, 'step');
|
|
92
|
+
for (const job of chain.h.jobTraceparents) {
|
|
93
|
+
const parsed = parseTraceparent(job);
|
|
94
|
+
assert.ok(parsed, 'a workflow job lost its trace');
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
/** The instance's trace id, wherever the chain got far enough to show it. */
|
|
98
|
+
const traceOf = (chain, instance) => instance.entries[0]?.traceId ??
|
|
99
|
+
instance.errorId ??
|
|
100
|
+
chain.logs.find(({ level, scope }) => level === 'error' && scope)?.scope;
|
|
101
|
+
const assertFailure = (chain, instance) => {
|
|
102
|
+
const { start, hop, outcome } = chain.combo;
|
|
103
|
+
const traceId = traceOf(chain, instance);
|
|
104
|
+
assert.ok(traceId, 'the chain never showed a trace');
|
|
105
|
+
if (['http', 'channel', 'mcp'].includes(start) && instance.callerRefused) {
|
|
106
|
+
assert.ok(instance.errorId, 'the caller was given no errorId');
|
|
107
|
+
}
|
|
108
|
+
if (instance.errorId) {
|
|
109
|
+
assert.equal(instance.errorId, traceId, 'errorId is not the trace id');
|
|
110
|
+
}
|
|
111
|
+
if (hop === 'mesh' && instance.calleeErrorId && outcome !== 'auth-mesh-jwt') {
|
|
112
|
+
assert.ok(instance.entries.some(({ traceId: id }) => id === instance.calleeErrorId), 'the callee errorId differs');
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Every trace id the chain owns. A job refused before its function runs shows
|
|
117
|
+
* no entry, so what it owns is the trace it carried, or else the one each
|
|
118
|
+
* delivery started and logged under.
|
|
119
|
+
*/
|
|
120
|
+
const tracesOf = (chain) => {
|
|
121
|
+
const carried = (instance) => inboundContinues(chain.combo.start, chain.inbound) && !chain.meshGuardsEntry
|
|
122
|
+
? [parseTraceparent(instance.sent)?.traceId]
|
|
123
|
+
: [];
|
|
124
|
+
const refused = chain.instances.some(({ entries }) => !entries.length);
|
|
125
|
+
return new Set(chain.instances
|
|
126
|
+
.flatMap((instance) => [
|
|
127
|
+
traceOf(chain, instance),
|
|
128
|
+
...carried(instance),
|
|
129
|
+
...instance.entries.map((entry) => entry.traceId),
|
|
130
|
+
])
|
|
131
|
+
.concat(refused ? chain.logs.map(({ scope }) => scope) : []));
|
|
132
|
+
};
|
|
133
|
+
const assertLogs = (chain) => {
|
|
134
|
+
const traces = tracesOf(chain);
|
|
135
|
+
for (const { level, scope, message } of chain.logs) {
|
|
136
|
+
if (level === 'error') {
|
|
137
|
+
assert.ok(scope !== undefined, `an error logged with no trace: ${JSON.stringify(message)?.slice(0, 120)}`);
|
|
138
|
+
}
|
|
139
|
+
if (scope !== undefined && chain.combo.outcome !== 'auth-mesh-jwt') {
|
|
140
|
+
assert.ok(traces.has(scope), `a log carries a stray trace ${scope}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const assertTelemetry = (chain) => {
|
|
145
|
+
const seen = tracesOf(chain);
|
|
146
|
+
for (const { message } of chain.logs) {
|
|
147
|
+
const record = message;
|
|
148
|
+
if (!record?.__pikku_telemetry)
|
|
149
|
+
continue;
|
|
150
|
+
assert.ok(seen.has(record.traceId), `telemetry carries ${record.traceId}, not a trace of this chain`);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
/** Chains running at once must not share or swap anything. */
|
|
154
|
+
const assertIsolated = (chain) => {
|
|
155
|
+
if (chain.instances.length < 2)
|
|
156
|
+
return;
|
|
157
|
+
const owners = new Map();
|
|
158
|
+
const own = (traceId, index) => {
|
|
159
|
+
const owner = owners.get(traceId) ?? index;
|
|
160
|
+
assert.equal(owner, index, 'two concurrent starts share a trace');
|
|
161
|
+
owners.set(traceId, index);
|
|
162
|
+
};
|
|
163
|
+
for (const instance of chain.instances) {
|
|
164
|
+
const { index, entries, targets, outgoing } = instance;
|
|
165
|
+
own(traceOf(chain, instance), index);
|
|
166
|
+
for (const { traceId } of [...entries, ...targets])
|
|
167
|
+
own(traceId, index);
|
|
168
|
+
for (const hop of outgoing)
|
|
169
|
+
own(parseTraceparent(hop)?.traceId, index);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
/** What every generated test asserts about a chain, once it has run. */
|
|
173
|
+
export const assertChain = async (chain) => {
|
|
174
|
+
for (const instance of chain.instances) {
|
|
175
|
+
assertEntry(chain, instance);
|
|
176
|
+
assertHops(chain, instance);
|
|
177
|
+
await assertRun(chain, instance);
|
|
178
|
+
if (outcomeError(chain.combo.outcome) || instance.failure) {
|
|
179
|
+
assertFailure(chain, instance);
|
|
180
|
+
}
|
|
181
|
+
if (chain.timing === 'job-after-parent') {
|
|
182
|
+
for (const startedAt of instance.targetStartedAt) {
|
|
183
|
+
assert.ok(startedAt >= instance.entryFinishedAt, 'ran before the parent finished');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
assertLogs(chain);
|
|
188
|
+
assertTelemetry(chain);
|
|
189
|
+
assertIsolated(chain);
|
|
190
|
+
};
|