@pikku/core 0.12.28 → 0.12.30
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 +70 -0
- package/dist/services/meta-service.js +15 -7
- package/dist/types/core.types.d.ts +9 -1
- package/dist/wirings/http/pikku-fetch-http-request.js +42 -8
- package/dist/wirings/secret/secret.types.d.ts +7 -0
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +5 -1
- package/dist/wirings/workflow/graph/graph-runner.js +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +11 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +37 -23
- package/dist/wirings/workflow/workflow.types.d.ts +0 -3
- package/package.json +1 -1
- package/src/services/meta-service.ts +19 -7
- package/src/types/core.types.ts +9 -0
- package/src/wirings/http/pikku-fetch-http-request.ts +43 -8
- package/src/wirings/secret/secret.types.ts +7 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +5 -1
- package/src/wirings/workflow/graph/graph-runner.test.ts +1 -2
- package/src/wirings/workflow/graph/graph-runner.ts +1 -2
- package/src/wirings/workflow/pikku-workflow-service.test.ts +168 -19
- package/src/wirings/workflow/pikku-workflow-service.ts +41 -25
- package/src/wirings/workflow/workflow.types.ts +0 -3
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,73 @@
|
|
|
1
|
+
## 0.12.30
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- cd101a5: feat(core): add `auditLog` service slot for per-invocation audit logs
|
|
6
|
+
|
|
7
|
+
`CoreSingletonServices` now declares `auditLog?: AuditLog`, giving the
|
|
8
|
+
per-request audit log returned by `createInvocationAudit` a typed home in the
|
|
9
|
+
service container. Apps wire it in `createWireServices`
|
|
10
|
+
(`return { auditLog, kysely: createAuditedKysely(kysely, { audit: auditLog }) }`)
|
|
11
|
+
and the runner flushes its buffer via `close()` when the invocation ends.
|
|
12
|
+
|
|
13
|
+
Previously there was no slot to return it from: `audit` is typed `AuditService`
|
|
14
|
+
(the durable sink, `.audit()`), while `createInvocationAudit` returns an
|
|
15
|
+
`AuditLog` (the request-scoped buffer, `.write/.flush/.close`). Returning the
|
|
16
|
+
buffer under `audit` was a type error, so audited-Kysely wiring could not
|
|
17
|
+
type-check. `auditLog` is distinct from `audit` and never shadows it.
|
|
18
|
+
|
|
19
|
+
- ac16265: fix(core): read email template assets from the absolute `emailsMeta.src` directly
|
|
20
|
+
|
|
21
|
+
`getEmailTemplateAssets` passed an absolute `baseDir` (e.g. `/project/emails`) into
|
|
22
|
+
`readProjectFile`, which resolves `join(basePath, '..', relativePath)`. Because
|
|
23
|
+
`path.join` does not treat an absolute second segment as a root reset, this produced
|
|
24
|
+
a non-existent compound path (`/project/packages/functions/project/emails/...`), so
|
|
25
|
+
every asset read returned `null` and the email preview reported all source files
|
|
26
|
+
(`theme, locale, html, subject, text`) as missing. Read the assets directly via
|
|
27
|
+
`readFile(join(baseDir, rel))` instead, which resolves correctly for an absolute
|
|
28
|
+
base. Verified live: a previously all-missing preview now renders.
|
|
29
|
+
|
|
30
|
+
- a05e864: fix(core): allow multiple independent suspend points in one workflow
|
|
31
|
+
|
|
32
|
+
`getSuspendStepName()` returned the constant `'__workflow_suspend'` for every
|
|
33
|
+
`workflow.suspend()` call, so all suspends in a run shared a single step row.
|
|
34
|
+
Once the first suspend resolved (row → `succeeded`), every later `suspend()`
|
|
35
|
+
read that same `succeeded` row and fell straight through without pausing — so a
|
|
36
|
+
workflow could only ever have one working suspend point, and a second one (e.g.
|
|
37
|
+
wait-for-build, then wait-for-approval) was silently skipped.
|
|
38
|
+
|
|
39
|
+
The suspend step is now keyed on its `reason` (used raw, just namespaced so it
|
|
40
|
+
can't collide with a `do`/`sleep` step of the same name), so each distinct
|
|
41
|
+
reason is its own step row. A workflow can now have multiple independent
|
|
42
|
+
suspends, including dynamic reasons in loops (`suspend(`Wait for ${i}`)`),
|
|
43
|
+
exactly like dynamic `do()` step names. As with `do()`/`sleep()`, the reason is
|
|
44
|
+
the suspend's stable identity and must be derived deterministically so it
|
|
45
|
+
matches on replay. `suspend(reason)` is unchanged at the call site.
|
|
46
|
+
|
|
47
|
+
- 20750fd: feat(workflow): decide step dispatch purely per-function
|
|
48
|
+
|
|
49
|
+
Workflow step execution (inline vs queue dispatch) is now decided entirely by
|
|
50
|
+
the step's function `inline` flag — the workflow-level / run-level `inline`
|
|
51
|
+
meta no longer participates in per-step dispatch.
|
|
52
|
+
- Steps default to **inline**, so a normally-started (queue-backed) workflow
|
|
53
|
+
runs its whole chain in one orchestrator pass instead of one queue
|
|
54
|
+
round-trip per step.
|
|
55
|
+
- A function marked `inline: false` is dispatched via the queue (its own
|
|
56
|
+
worker, retry isolation). When `inline: false` but no `queueService` is
|
|
57
|
+
configured, the step falls back to inline and emits a `logger.warn` instead
|
|
58
|
+
of silently swallowing the misconfiguration.
|
|
59
|
+
- Removed the now-unused workflow-level `inline` from `WorkflowsMeta` /
|
|
60
|
+
`WorkflowRuntimeMeta`, the inspector's workflow extraction, the DSL→graph
|
|
61
|
+
converter, and the deploy analyzer / service inference (which now key off
|
|
62
|
+
the per-function flag). Run-level `inline` is retained: it still controls
|
|
63
|
+
whether a whole run executes in-process without queue infrastructure.
|
|
64
|
+
|
|
65
|
+
## 0.12.29
|
|
66
|
+
|
|
67
|
+
### Patch Changes
|
|
68
|
+
|
|
69
|
+
- 294e365: Fix body stream caching in PikkuFetchHTTPRequest so that arrayBuffer() can be called after body() has already consumed the stream via text(). This is required for Auth.js CSRF validation to work correctly when integrated with Pikku's internal fetch.
|
|
70
|
+
|
|
1
71
|
## 0.12.28
|
|
2
72
|
|
|
3
73
|
### Patch Changes
|
|
@@ -306,6 +306,20 @@ export class LocalMetaService {
|
|
|
306
306
|
readEmailFile(`templates/${templateName}.subject.txt`),
|
|
307
307
|
readEmailFile(`templates/${templateName}.text.txt`),
|
|
308
308
|
]);
|
|
309
|
+
const missing = [
|
|
310
|
+
...(themeRaw ? [] : ['theme']),
|
|
311
|
+
...(localeRaw ? [] : ['locale']),
|
|
312
|
+
...(templateHtml ? [] : ['html']),
|
|
313
|
+
...(templateSubject ? [] : ['subject']),
|
|
314
|
+
...(templateText ? [] : ['text']),
|
|
315
|
+
];
|
|
316
|
+
if (missing.length > 0) {
|
|
317
|
+
// Diagnostic for the intermittent "Missing source files" report: logs which
|
|
318
|
+
// process and which resolved baseDir saw the gap. Different pids across
|
|
319
|
+
// failures => multiple runtime workers with divergent cached `src`; same pid
|
|
320
|
+
// + transient absence => files being rewritten under us (e.g. regeneration).
|
|
321
|
+
console.warn(`[email-assets] pid=${process.pid} template=${templateName} locale=${locale} baseDir=${baseDir} missing=${missing.join(',')}`);
|
|
322
|
+
}
|
|
309
323
|
return {
|
|
310
324
|
theme: themeRaw ? JSON.parse(themeRaw) : {},
|
|
311
325
|
strings: localeRaw
|
|
@@ -318,13 +332,7 @@ export class LocalMetaService {
|
|
|
318
332
|
html: templateHtml ?? '',
|
|
319
333
|
subject: templateSubject ?? '',
|
|
320
334
|
text: templateText ?? '',
|
|
321
|
-
missing
|
|
322
|
-
...(themeRaw ? [] : ['theme']),
|
|
323
|
-
...(localeRaw ? [] : ['locale']),
|
|
324
|
-
...(templateHtml ? [] : ['html']),
|
|
325
|
-
...(templateSubject ? [] : ['subject']),
|
|
326
|
-
...(templateText ? [] : ['text']),
|
|
327
|
-
],
|
|
335
|
+
missing,
|
|
328
336
|
};
|
|
329
337
|
}
|
|
330
338
|
async getServicesMeta() {
|
|
@@ -28,7 +28,7 @@ import type { CredentialService } from '../services/credential-service.js';
|
|
|
28
28
|
import type { EmailService } from '../services/email-service.js';
|
|
29
29
|
import type { MetaService } from '../services/meta-service.js';
|
|
30
30
|
import type { SessionStore } from '../services/session-store.js';
|
|
31
|
-
import type { AuditDurability, AuditService } from '../services/audit-service.js';
|
|
31
|
+
import type { AuditDurability, AuditLog, AuditService } from '../services/audit-service.js';
|
|
32
32
|
export type PikkuWiringTypes = 'http' | 'scheduler' | 'trigger' | 'channel' | 'rpc' | 'queue' | 'mcp' | 'cli' | 'workflow' | 'agent' | 'gateway';
|
|
33
33
|
export interface FunctionServicesMeta {
|
|
34
34
|
optimized: boolean;
|
|
@@ -196,6 +196,14 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
|
|
|
196
196
|
metaService?: MetaService;
|
|
197
197
|
/** Audit service for durable or staged audit event capture */
|
|
198
198
|
audit?: AuditService;
|
|
199
|
+
/**
|
|
200
|
+
* Per-invocation audit log. Typically created in `createWireServices` via
|
|
201
|
+
* `createInvocationAudit(audit, wire)` and returned as a wire service so the
|
|
202
|
+
* runner flushes its buffer (via `close()`) when the invocation ends. Distinct
|
|
203
|
+
* from `audit` (the durable sink): this is the request-scoped buffer that
|
|
204
|
+
* writes into it.
|
|
205
|
+
*/
|
|
206
|
+
auditLog?: AuditLog;
|
|
199
207
|
/** Session store for persisting user sessions keyed by pikkuUserId */
|
|
200
208
|
sessionStore?: SessionStore;
|
|
201
209
|
}
|
|
@@ -11,6 +11,9 @@ export class PikkuFetchHTTPRequest {
|
|
|
11
11
|
#cookies;
|
|
12
12
|
#params = {};
|
|
13
13
|
#url;
|
|
14
|
+
#rawBodyText;
|
|
15
|
+
#rawBodyBuffer;
|
|
16
|
+
#bodyRead = false;
|
|
14
17
|
constructor(request) {
|
|
15
18
|
this.request = request;
|
|
16
19
|
this.#url = new URL(request.url);
|
|
@@ -25,15 +28,45 @@ export class PikkuFetchHTTPRequest {
|
|
|
25
28
|
* Retrieves the request body.
|
|
26
29
|
* @returns A promise that resolves to the request body.
|
|
27
30
|
*/
|
|
28
|
-
json() {
|
|
29
|
-
|
|
31
|
+
async json() {
|
|
32
|
+
const text = await this.#readRawText();
|
|
33
|
+
return JSON.parse(text);
|
|
30
34
|
}
|
|
31
35
|
/**
|
|
32
36
|
* Retrieves the raw request body as a Buffer.
|
|
33
37
|
* @returns A promise that resolves to the raw request body.
|
|
34
38
|
*/
|
|
35
|
-
arrayBuffer() {
|
|
36
|
-
|
|
39
|
+
async arrayBuffer() {
|
|
40
|
+
if (this.#rawBodyBuffer !== undefined) {
|
|
41
|
+
return this.#rawBodyBuffer;
|
|
42
|
+
}
|
|
43
|
+
if (this.#bodyRead) {
|
|
44
|
+
if (this.#rawBodyText !== undefined) {
|
|
45
|
+
const buf = new TextEncoder().encode(this.#rawBodyText)
|
|
46
|
+
.buffer;
|
|
47
|
+
this.#rawBodyBuffer = buf;
|
|
48
|
+
return buf;
|
|
49
|
+
}
|
|
50
|
+
return new ArrayBuffer(0);
|
|
51
|
+
}
|
|
52
|
+
const buf = await this.request.arrayBuffer();
|
|
53
|
+
this.#rawBodyBuffer = buf;
|
|
54
|
+
this.#bodyRead = true;
|
|
55
|
+
return buf;
|
|
56
|
+
}
|
|
57
|
+
async #readRawText() {
|
|
58
|
+
if (this.#rawBodyText !== undefined) {
|
|
59
|
+
return this.#rawBodyText;
|
|
60
|
+
}
|
|
61
|
+
if (this.#rawBodyBuffer !== undefined) {
|
|
62
|
+
const text = new TextDecoder().decode(this.#rawBodyBuffer);
|
|
63
|
+
this.#rawBodyText = text;
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
const text = await this.request.text();
|
|
67
|
+
this.#rawBodyText = text;
|
|
68
|
+
this.#bodyRead = true;
|
|
69
|
+
return text;
|
|
37
70
|
}
|
|
38
71
|
headers() {
|
|
39
72
|
return Object.fromEntries(this.request.headers.entries());
|
|
@@ -111,7 +144,8 @@ export class PikkuFetchHTTPRequest {
|
|
|
111
144
|
const contentType = this.header('content-type') || '';
|
|
112
145
|
try {
|
|
113
146
|
if (contentType.includes('application/json')) {
|
|
114
|
-
const
|
|
147
|
+
const text = await this.#readRawText();
|
|
148
|
+
const parsed = text ? JSON.parse(text) : null;
|
|
115
149
|
body =
|
|
116
150
|
typeof parsed === 'object' &&
|
|
117
151
|
parsed !== null &&
|
|
@@ -120,15 +154,15 @@ export class PikkuFetchHTTPRequest {
|
|
|
120
154
|
: { data: parsed };
|
|
121
155
|
}
|
|
122
156
|
else if (contentType.includes('text/')) {
|
|
123
|
-
const text = await this
|
|
157
|
+
const text = await this.#readRawText();
|
|
124
158
|
body = { data: text };
|
|
125
159
|
}
|
|
126
160
|
else if (contentType.includes('application/octet-stream')) {
|
|
127
|
-
const buffer = await this.
|
|
161
|
+
const buffer = await this.arrayBuffer();
|
|
128
162
|
body = { data: buffer };
|
|
129
163
|
}
|
|
130
164
|
else if (contentType === 'application/x-www-form-urlencoded') {
|
|
131
|
-
const text = await this
|
|
165
|
+
const text = await this.#readRawText();
|
|
132
166
|
const params = new URLSearchParams(text);
|
|
133
167
|
let count = 0;
|
|
134
168
|
for (const _ of params) {
|
|
@@ -4,6 +4,12 @@ export type CoreSecret<T = unknown> = {
|
|
|
4
4
|
description?: string;
|
|
5
5
|
secretId: string;
|
|
6
6
|
schema: T;
|
|
7
|
+
/**
|
|
8
|
+
* Optional rotation cadence for this secret, e.g. '1d', '30day', '1w'.
|
|
9
|
+
* Stored in the generated secrets metadata so consumers can tell when a
|
|
10
|
+
* secret was last updated and whether it is due for rotation.
|
|
11
|
+
*/
|
|
12
|
+
rotationPeriod?: string;
|
|
7
13
|
};
|
|
8
14
|
export type OAuth2CredentialConfig = {
|
|
9
15
|
tokenSecretId: string;
|
|
@@ -20,6 +26,7 @@ export type SecretDefinitionMeta = {
|
|
|
20
26
|
secretId: string;
|
|
21
27
|
schema?: Record<string, unknown> | string;
|
|
22
28
|
oauth2?: OAuth2CredentialConfig;
|
|
29
|
+
rotationPeriod?: string;
|
|
23
30
|
sourceFile?: string;
|
|
24
31
|
};
|
|
25
32
|
export type SecretDefinitionsMeta = Record<string, SecretDefinitionMeta>;
|
|
@@ -33,6 +33,7 @@ export function validateAndBuildSecretDefinitionsMeta(definitions, schemaLookup)
|
|
|
33
33
|
secretId: def.secretId,
|
|
34
34
|
schema: def.schema,
|
|
35
35
|
oauth2: def.oauth2,
|
|
36
|
+
rotationPeriod: def.rotationPeriod,
|
|
36
37
|
sourceFile: def.sourceFile,
|
|
37
38
|
};
|
|
38
39
|
}
|
|
@@ -47,6 +48,7 @@ export function validateAndBuildSecretDefinitionsMeta(definitions, schemaLookup)
|
|
|
47
48
|
secretId: def.secretId,
|
|
48
49
|
schema: def.schema,
|
|
49
50
|
oauth2: def.oauth2,
|
|
51
|
+
rotationPeriod: def.rotationPeriod,
|
|
50
52
|
sourceFile: def.sourceFile,
|
|
51
53
|
};
|
|
52
54
|
}
|
|
@@ -27,7 +27,11 @@ export type WorkflowWireDoInline = <T>(stepName: string, fn: () => Promise<T> |
|
|
|
27
27
|
*/
|
|
28
28
|
export type WorkflowWireSleep = (stepName: string, duration: string) => Promise<void>;
|
|
29
29
|
/**
|
|
30
|
-
* Type signature for workflow.suspend() - used by inspector
|
|
30
|
+
* Type signature for workflow.suspend() - used by inspector.
|
|
31
|
+
* `reason` is both the human-readable message stored on the suspended run and
|
|
32
|
+
* the suspend point's stable identity (used raw as the durable step name), so a
|
|
33
|
+
* workflow can have multiple independent suspends — including dynamic reasons in
|
|
34
|
+
* loops, like dynamic `do()` step names.
|
|
31
35
|
*/
|
|
32
36
|
export type WorkflowWireSuspend = (reason: string) => Promise<void>;
|
|
33
37
|
/**
|
|
@@ -359,7 +359,7 @@ export async function executeGraphStep(workflowService, rpcService, runId, stepI
|
|
|
359
359
|
parentRunId: runId,
|
|
360
360
|
parentStepId: stepId,
|
|
361
361
|
};
|
|
362
|
-
const shouldInline =
|
|
362
|
+
const shouldInline = !getSingletonServices()?.queueService;
|
|
363
363
|
const { runId: childRunId } = await workflowService.startWorkflow(rpcName, data, childWire, rpcService, { inline: shouldInline });
|
|
364
364
|
await workflowService.setStepChildRunId(stepId, childRunId);
|
|
365
365
|
if (shouldInline) {
|
|
@@ -335,6 +335,17 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
335
335
|
private rpcStep;
|
|
336
336
|
private inlineStep;
|
|
337
337
|
private sleepStep;
|
|
338
|
+
/**
|
|
339
|
+
* Derive the durable step name for a suspend point from its `reason`, so each
|
|
340
|
+
* distinct reason is its own step row — letting one workflow have multiple
|
|
341
|
+
* independent suspends (e.g. wait-for-build, then wait-for-approval), and
|
|
342
|
+
* supporting dynamic reasons in loops (`suspend(`Wait for ${i}`)`) exactly
|
|
343
|
+
* like dynamic `do()` step names. The reason is used raw (it's just a text
|
|
344
|
+
* step name), only namespaced so it can't collide with a `do`/`sleep` step of
|
|
345
|
+
* the same name. Like `do()` / `sleep()`, the reason is the step's stable
|
|
346
|
+
* identity: it MUST be derived deterministically so it's the same every time
|
|
347
|
+
* the workflow replays through that point.
|
|
348
|
+
*/
|
|
338
349
|
private getSuspendStepName;
|
|
339
350
|
private suspendStep;
|
|
340
351
|
createWorkflowWire(name: string, runId: string, rpcService: any, addonNamespace?: string | null): PikkuWorkflowWire;
|
|
@@ -484,16 +484,25 @@ export class PikkuWorkflowService {
|
|
|
484
484
|
* through to the inline execution path.
|
|
485
485
|
*/
|
|
486
486
|
async dispatchStep(runId, stepName, rpcName, data, stepOptions) {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
//
|
|
491
|
-
//
|
|
487
|
+
// Step execution is decided purely by the function's `inline` flag (default
|
|
488
|
+
// true). Only a function explicitly marked `inline: false` dispatches via
|
|
489
|
+
// the queue. The run-level inline state is intentionally NOT consulted
|
|
490
|
+
// here: default steps run inline even inside a queue-dispatched run, so a
|
|
491
|
+
// normally-started workflow executes its steps in one orchestrator-worker
|
|
492
|
+
// pass instead of one queue round-trip per step.
|
|
492
493
|
const functionsMeta = pikkuState(null, 'function', 'meta');
|
|
493
494
|
const rpcFuncId = pikkuState(null, 'rpc', 'meta')[rpcName];
|
|
494
495
|
const rpcMeta = typeof rpcFuncId === 'string' ? functionsMeta[rpcFuncId] : undefined;
|
|
495
496
|
const forceQueue = rpcMeta?.inline === false;
|
|
496
|
-
if (!forceQueue
|
|
497
|
+
if (!forceQueue) {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
// The function opted out of inline execution (`inline: false`) but no queue
|
|
501
|
+
// service is configured to dispatch it. Fall back to inline so the workflow
|
|
502
|
+
// still progresses, but warn loudly — silently swallowing this hides a real
|
|
503
|
+
// misconfiguration (the step won't get its own worker/retry isolation).
|
|
504
|
+
if (!getSingletonServices()?.queueService) {
|
|
505
|
+
getSingletonServices()?.logger.warn(`Workflow step '${stepName}' (function '${rpcName}') is marked 'inline: false' but no queue service is configured — running it inline instead of dispatching to a queue.`);
|
|
497
506
|
return false;
|
|
498
507
|
}
|
|
499
508
|
const retries = stepOptions?.retries ?? 0;
|
|
@@ -553,9 +562,7 @@ export class PikkuWorkflowService {
|
|
|
553
562
|
}
|
|
554
563
|
if (workflowMeta.source === 'graph' ||
|
|
555
564
|
workflowMeta.source === 'dynamic-workflow') {
|
|
556
|
-
const shouldInline = options?.inline ||
|
|
557
|
-
workflowMeta.inline ||
|
|
558
|
-
!getSingletonServices()?.queueService;
|
|
565
|
+
const shouldInline = options?.inline || !getSingletonServices()?.queueService;
|
|
559
566
|
return runWorkflowGraph(this, name, input, rpcService, shouldInline, options?.startNode, wire, workflowMeta);
|
|
560
567
|
}
|
|
561
568
|
// DSL workflow - check registration exists
|
|
@@ -567,9 +574,7 @@ export class PikkuWorkflowService {
|
|
|
567
574
|
if (!workflowMeta.graphHash) {
|
|
568
575
|
throw new Error(`Missing workflow graphHash for '${name}'`);
|
|
569
576
|
}
|
|
570
|
-
const shouldInline = options?.inline ||
|
|
571
|
-
workflowMeta.inline ||
|
|
572
|
-
!getSingletonServices()?.queueService;
|
|
577
|
+
const shouldInline = options?.inline || !getSingletonServices()?.queueService;
|
|
573
578
|
const runId = await this.createRun(name, input, shouldInline, workflowMeta.graphHash, wire, {
|
|
574
579
|
deterministic: workflowMeta.deterministic,
|
|
575
580
|
plannedSteps: workflowMeta.plannedSteps,
|
|
@@ -808,7 +813,7 @@ export class PikkuWorkflowService {
|
|
|
808
813
|
parentRunId: runId,
|
|
809
814
|
parentStepId: stepState.stepId,
|
|
810
815
|
};
|
|
811
|
-
const shouldInline =
|
|
816
|
+
const shouldInline = !getSingletonServices()?.queueService;
|
|
812
817
|
const { runId: childRunId } = await this.startWorkflow(rpcName, data, childWire, rpcService, { inline: shouldInline });
|
|
813
818
|
await this.setStepChildRunId(stepState.stepId, childRunId);
|
|
814
819
|
if (shouldInline) {
|
|
@@ -1115,26 +1120,34 @@ export class PikkuWorkflowService {
|
|
|
1115
1120
|
await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(duration)));
|
|
1116
1121
|
await this.setStepResult(stepState.stepId, null);
|
|
1117
1122
|
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Derive the durable step name for a suspend point from its `reason`, so each
|
|
1125
|
+
* distinct reason is its own step row — letting one workflow have multiple
|
|
1126
|
+
* independent suspends (e.g. wait-for-build, then wait-for-approval), and
|
|
1127
|
+
* supporting dynamic reasons in loops (`suspend(`Wait for ${i}`)`) exactly
|
|
1128
|
+
* like dynamic `do()` step names. The reason is used raw (it's just a text
|
|
1129
|
+
* step name), only namespaced so it can't collide with a `do`/`sleep` step of
|
|
1130
|
+
* the same name. Like `do()` / `sleep()`, the reason is the step's stable
|
|
1131
|
+
* identity: it MUST be derived deterministically so it's the same every time
|
|
1132
|
+
* the workflow replays through that point.
|
|
1133
|
+
*/
|
|
1118
1134
|
getSuspendStepName(reason) {
|
|
1119
|
-
|
|
1120
|
-
return '__workflow_suspend';
|
|
1121
|
-
}
|
|
1122
|
-
return '__workflow_suspend';
|
|
1135
|
+
return `__workflow_suspend:${reason}`;
|
|
1123
1136
|
}
|
|
1124
1137
|
async suspendStep(runId, reason) {
|
|
1125
|
-
const
|
|
1126
|
-
await this.withStepLock(runId,
|
|
1138
|
+
const suspendStepName = this.getSuspendStepName(reason);
|
|
1139
|
+
await this.withStepLock(runId, suspendStepName, async () => {
|
|
1127
1140
|
let stepState;
|
|
1128
1141
|
try {
|
|
1129
|
-
stepState = await this.getStepState(runId,
|
|
1142
|
+
stepState = await this.getStepState(runId, suspendStepName);
|
|
1130
1143
|
}
|
|
1131
1144
|
catch {
|
|
1132
|
-
stepState = await this.insertStepState(runId,
|
|
1145
|
+
stepState = await this.insertStepState(runId, suspendStepName, 'pikkuWorkflowSuspend', {
|
|
1133
1146
|
reason,
|
|
1134
1147
|
});
|
|
1135
1148
|
}
|
|
1136
1149
|
if (!stepState.stepId) {
|
|
1137
|
-
stepState = await this.insertStepState(runId,
|
|
1150
|
+
stepState = await this.insertStepState(runId, suspendStepName, 'pikkuWorkflowSuspend', {
|
|
1138
1151
|
reason,
|
|
1139
1152
|
});
|
|
1140
1153
|
}
|
|
@@ -1175,7 +1188,8 @@ export class PikkuWorkflowService {
|
|
|
1175
1188
|
await this.sleepStep(runId, stepName, getDurationInMilliseconds(duration));
|
|
1176
1189
|
},
|
|
1177
1190
|
suspend: async (reason) => {
|
|
1178
|
-
|
|
1191
|
+
this.verifyStepName(reason);
|
|
1192
|
+
await this.suspendStep(runId, reason);
|
|
1179
1193
|
},
|
|
1180
1194
|
};
|
|
1181
1195
|
return workflowWire;
|
|
@@ -228,7 +228,6 @@ export type WorkflowsMeta = Record<string, CommonWireMeta & {
|
|
|
228
228
|
steps: WorkflowStepMeta[];
|
|
229
229
|
context?: WorkflowContext;
|
|
230
230
|
dsl?: boolean;
|
|
231
|
-
inline?: boolean;
|
|
232
231
|
expose?: boolean;
|
|
233
232
|
}>;
|
|
234
233
|
/**
|
|
@@ -247,8 +246,6 @@ export interface WorkflowRuntimeMeta {
|
|
|
247
246
|
description?: string;
|
|
248
247
|
/** Tags for organization */
|
|
249
248
|
tags?: string[];
|
|
250
|
-
/** If true, workflow always executes inline without queues */
|
|
251
|
-
inline?: boolean;
|
|
252
249
|
/** Serialized nodes */
|
|
253
250
|
nodes?: Record<string, any>;
|
|
254
251
|
/** Entry node IDs for graph workflows (computed at build time) */
|
package/package.json
CHANGED
|
@@ -562,6 +562,24 @@ export class LocalMetaService implements MetaService {
|
|
|
562
562
|
readEmailFile(`templates/${templateName}.text.txt`),
|
|
563
563
|
])
|
|
564
564
|
|
|
565
|
+
const missing = [
|
|
566
|
+
...(themeRaw ? [] : ['theme']),
|
|
567
|
+
...(localeRaw ? [] : ['locale']),
|
|
568
|
+
...(templateHtml ? [] : ['html']),
|
|
569
|
+
...(templateSubject ? [] : ['subject']),
|
|
570
|
+
...(templateText ? [] : ['text']),
|
|
571
|
+
]
|
|
572
|
+
|
|
573
|
+
if (missing.length > 0) {
|
|
574
|
+
// Diagnostic for the intermittent "Missing source files" report: logs which
|
|
575
|
+
// process and which resolved baseDir saw the gap. Different pids across
|
|
576
|
+
// failures => multiple runtime workers with divergent cached `src`; same pid
|
|
577
|
+
// + transient absence => files being rewritten under us (e.g. regeneration).
|
|
578
|
+
console.warn(
|
|
579
|
+
`[email-assets] pid=${process.pid} template=${templateName} locale=${locale} baseDir=${baseDir} missing=${missing.join(',')}`
|
|
580
|
+
)
|
|
581
|
+
}
|
|
582
|
+
|
|
565
583
|
return {
|
|
566
584
|
theme: themeRaw ? (JSON.parse(themeRaw) as Record<string, unknown>) : {},
|
|
567
585
|
strings: localeRaw
|
|
@@ -574,13 +592,7 @@ export class LocalMetaService implements MetaService {
|
|
|
574
592
|
html: templateHtml ?? '',
|
|
575
593
|
subject: templateSubject ?? '',
|
|
576
594
|
text: templateText ?? '',
|
|
577
|
-
missing
|
|
578
|
-
...(themeRaw ? [] : ['theme']),
|
|
579
|
-
...(localeRaw ? [] : ['locale']),
|
|
580
|
-
...(templateHtml ? [] : ['html']),
|
|
581
|
-
...(templateSubject ? [] : ['subject']),
|
|
582
|
-
...(templateText ? [] : ['text']),
|
|
583
|
-
],
|
|
595
|
+
missing,
|
|
584
596
|
}
|
|
585
597
|
}
|
|
586
598
|
|
package/src/types/core.types.ts
CHANGED
|
@@ -40,6 +40,7 @@ import type { MetaService } from '../services/meta-service.js'
|
|
|
40
40
|
import type { SessionStore } from '../services/session-store.js'
|
|
41
41
|
import type {
|
|
42
42
|
AuditDurability,
|
|
43
|
+
AuditLog,
|
|
43
44
|
AuditService,
|
|
44
45
|
} from '../services/audit-service.js'
|
|
45
46
|
|
|
@@ -255,6 +256,14 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
|
|
|
255
256
|
metaService?: MetaService
|
|
256
257
|
/** Audit service for durable or staged audit event capture */
|
|
257
258
|
audit?: AuditService
|
|
259
|
+
/**
|
|
260
|
+
* Per-invocation audit log. Typically created in `createWireServices` via
|
|
261
|
+
* `createInvocationAudit(audit, wire)` and returned as a wire service so the
|
|
262
|
+
* runner flushes its buffer (via `close()`) when the invocation ends. Distinct
|
|
263
|
+
* from `audit` (the durable sink): this is the request-scoped buffer that
|
|
264
|
+
* writes into it.
|
|
265
|
+
*/
|
|
266
|
+
auditLog?: AuditLog
|
|
258
267
|
/** Session store for persisting user sessions keyed by pikkuUserId */
|
|
259
268
|
sessionStore?: SessionStore
|
|
260
269
|
}
|
|
@@ -14,6 +14,9 @@ export class PikkuFetchHTTPRequest<
|
|
|
14
14
|
#cookies: Partial<Record<string, string>> | undefined
|
|
15
15
|
#params: Partial<Record<string, string | string[]>> = {}
|
|
16
16
|
#url: URL
|
|
17
|
+
#rawBodyText: string | undefined
|
|
18
|
+
#rawBodyBuffer: ArrayBuffer | undefined
|
|
19
|
+
#bodyRead = false
|
|
17
20
|
|
|
18
21
|
constructor(private request: Request) {
|
|
19
22
|
this.#url = new URL(request.url)
|
|
@@ -31,16 +34,47 @@ export class PikkuFetchHTTPRequest<
|
|
|
31
34
|
* Retrieves the request body.
|
|
32
35
|
* @returns A promise that resolves to the request body.
|
|
33
36
|
*/
|
|
34
|
-
public json(): Promise<In> {
|
|
35
|
-
|
|
37
|
+
public async json(): Promise<In> {
|
|
38
|
+
const text = await this.#readRawText()
|
|
39
|
+
return JSON.parse(text) as In
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
/**
|
|
39
43
|
* Retrieves the raw request body as a Buffer.
|
|
40
44
|
* @returns A promise that resolves to the raw request body.
|
|
41
45
|
*/
|
|
42
|
-
public arrayBuffer(): Promise<ArrayBuffer> {
|
|
43
|
-
|
|
46
|
+
public async arrayBuffer(): Promise<ArrayBuffer> {
|
|
47
|
+
if (this.#rawBodyBuffer !== undefined) {
|
|
48
|
+
return this.#rawBodyBuffer
|
|
49
|
+
}
|
|
50
|
+
if (this.#bodyRead) {
|
|
51
|
+
if (this.#rawBodyText !== undefined) {
|
|
52
|
+
const buf = new TextEncoder().encode(this.#rawBodyText)
|
|
53
|
+
.buffer as ArrayBuffer
|
|
54
|
+
this.#rawBodyBuffer = buf
|
|
55
|
+
return buf
|
|
56
|
+
}
|
|
57
|
+
return new ArrayBuffer(0)
|
|
58
|
+
}
|
|
59
|
+
const buf = await this.request.arrayBuffer()
|
|
60
|
+
this.#rawBodyBuffer = buf
|
|
61
|
+
this.#bodyRead = true
|
|
62
|
+
return buf
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async #readRawText(): Promise<string> {
|
|
66
|
+
if (this.#rawBodyText !== undefined) {
|
|
67
|
+
return this.#rawBodyText
|
|
68
|
+
}
|
|
69
|
+
if (this.#rawBodyBuffer !== undefined) {
|
|
70
|
+
const text = new TextDecoder().decode(this.#rawBodyBuffer)
|
|
71
|
+
this.#rawBodyText = text
|
|
72
|
+
return text
|
|
73
|
+
}
|
|
74
|
+
const text = await this.request.text()
|
|
75
|
+
this.#rawBodyText = text
|
|
76
|
+
this.#bodyRead = true
|
|
77
|
+
return text
|
|
44
78
|
}
|
|
45
79
|
|
|
46
80
|
public headers(): Record<string, string> {
|
|
@@ -133,7 +167,8 @@ export class PikkuFetchHTTPRequest<
|
|
|
133
167
|
const contentType = this.header('content-type') || ''
|
|
134
168
|
try {
|
|
135
169
|
if (contentType.includes('application/json')) {
|
|
136
|
-
const
|
|
170
|
+
const text = await this.#readRawText()
|
|
171
|
+
const parsed = text ? JSON.parse(text) : null
|
|
137
172
|
body =
|
|
138
173
|
typeof parsed === 'object' &&
|
|
139
174
|
parsed !== null &&
|
|
@@ -141,13 +176,13 @@ export class PikkuFetchHTTPRequest<
|
|
|
141
176
|
? parsed
|
|
142
177
|
: { data: parsed }
|
|
143
178
|
} else if (contentType.includes('text/')) {
|
|
144
|
-
const text = await this
|
|
179
|
+
const text = await this.#readRawText()
|
|
145
180
|
body = { data: text }
|
|
146
181
|
} else if (contentType.includes('application/octet-stream')) {
|
|
147
|
-
const buffer = await this.
|
|
182
|
+
const buffer = await this.arrayBuffer()
|
|
148
183
|
body = { data: buffer }
|
|
149
184
|
} else if (contentType === 'application/x-www-form-urlencoded') {
|
|
150
|
-
const text = await this
|
|
185
|
+
const text = await this.#readRawText()
|
|
151
186
|
const params = new URLSearchParams(text)
|
|
152
187
|
let count = 0
|
|
153
188
|
for (const _ of params) {
|
|
@@ -4,6 +4,12 @@ export type CoreSecret<T = unknown> = {
|
|
|
4
4
|
description?: string
|
|
5
5
|
secretId: string
|
|
6
6
|
schema: T
|
|
7
|
+
/**
|
|
8
|
+
* Optional rotation cadence for this secret, e.g. '1d', '30day', '1w'.
|
|
9
|
+
* Stored in the generated secrets metadata so consumers can tell when a
|
|
10
|
+
* secret was last updated and whether it is due for rotation.
|
|
11
|
+
*/
|
|
12
|
+
rotationPeriod?: string
|
|
7
13
|
}
|
|
8
14
|
|
|
9
15
|
export type OAuth2CredentialConfig = {
|
|
@@ -22,6 +28,7 @@ export type SecretDefinitionMeta = {
|
|
|
22
28
|
secretId: string
|
|
23
29
|
schema?: Record<string, unknown> | string
|
|
24
30
|
oauth2?: OAuth2CredentialConfig
|
|
31
|
+
rotationPeriod?: string
|
|
25
32
|
sourceFile?: string
|
|
26
33
|
}
|
|
27
34
|
|
|
@@ -57,6 +57,7 @@ export function validateAndBuildSecretDefinitionsMeta(
|
|
|
57
57
|
secretId: def.secretId,
|
|
58
58
|
schema: def.schema,
|
|
59
59
|
oauth2: def.oauth2,
|
|
60
|
+
rotationPeriod: def.rotationPeriod,
|
|
60
61
|
sourceFile: def.sourceFile,
|
|
61
62
|
}
|
|
62
63
|
}
|
|
@@ -73,6 +74,7 @@ export function validateAndBuildSecretDefinitionsMeta(
|
|
|
73
74
|
secretId: def.secretId,
|
|
74
75
|
schema: def.schema,
|
|
75
76
|
oauth2: def.oauth2,
|
|
77
|
+
rotationPeriod: def.rotationPeriod,
|
|
76
78
|
sourceFile: def.sourceFile,
|
|
77
79
|
}
|
|
78
80
|
}
|
|
@@ -46,7 +46,11 @@ export type WorkflowWireSleep = (
|
|
|
46
46
|
) => Promise<void>
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Type signature for workflow.suspend() - used by inspector
|
|
49
|
+
* Type signature for workflow.suspend() - used by inspector.
|
|
50
|
+
* `reason` is both the human-readable message stored on the suspended run and
|
|
51
|
+
* the suspend point's stable identity (used raw as the durable step name), so a
|
|
52
|
+
* workflow can have multiple independent suspends — including dynamic reasons in
|
|
53
|
+
* loops, like dynamic `do()` step names.
|
|
50
54
|
*/
|
|
51
55
|
export type WorkflowWireSuspend = (reason: string) => Promise<void>
|
|
52
56
|
|