@qaick/runtime 0.2.1 → 0.2.2

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/index.d.ts CHANGED
@@ -2,3 +2,4 @@ export * from './runtime.js';
2
2
  export { InMemoryActionExecutor } from '@qaick/executor';
3
3
  export type { ActionExecutionContext, ActionExecutionResult, ActionExecutor, ControlCommand, ExecutionControlState, ExecutorEvent, ExecutorRequest, ExecutorResult, ExecutorRun, ActReplayTarget, ActReplayHandle, OperationReplayHandle, ExecutorStepResult, } from '@qaick/executor';
4
4
  export { resolveDeclaredInputs } from '@qaick/executor';
5
+ export * from './script.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './runtime.js';
2
2
  export { InMemoryActionExecutor } from '@qaick/executor';
3
3
  export { resolveDeclaredInputs } from '@qaick/executor';
4
+ export * from './script.js';
package/dist/runtime.d.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { type ScriptBackend } from './script.js';
1
2
  import type { ActionExecutor, ControlCommand, ExecutionControlState, Executor, ExecutorConfiguration, ExecutorEvent, ExecutorRequest, ExecutorResult, ExecutorRun, ActReplayTarget, ActReplayHandle, OperationReplayHandle } from '@qaick/executor';
2
3
  export interface RuntimeConfiguration extends ExecutorConfiguration {
3
4
  executor?: Executor;
5
+ scriptBackend?: ScriptBackend;
4
6
  }
5
7
  export declare const RUNTIME_VERSION: string;
6
8
  export type RuntimeCapability = 'execution' | 'controls' | 'events' | 'results' | 'retries' | 'run-from-here' | 'action-executors';
package/dist/runtime.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { createExecutor } from '@qaick/executor';
2
+ import { ScriptActionExecutor } from './script.js';
2
3
  import packageJson from '../package.json' with { type: 'json' };
3
4
  export const RUNTIME_VERSION = packageJson.version;
4
5
  export function createRuntime(configuration = {}) {
5
- return new ComposedQaickRuntime(configuration.executor ?? createExecutor(configuration));
6
+ const executor = configuration.executor ?? createExecutor(configuration);
7
+ executor.registerActionExecutor(new ScriptActionExecutor(configuration.scriptBackend));
8
+ return new ComposedQaickRuntime(executor);
6
9
  }
7
10
  class ComposedQaickRuntime {
8
11
  executor;
@@ -0,0 +1,24 @@
1
+ import type { ActionExecutor, ActionExecutionContext } from '@qaick/executor';
2
+ export declare const SCRIPT_TIMEOUT_MS = 1000;
3
+ export declare const MAX_SCRIPT_SOURCE_LENGTH = 20000;
4
+ export declare const MAX_SCRIPT_JSON_SIZE = 100000;
5
+ export interface ScriptJob {
6
+ source: string;
7
+ inputs: Record<string, unknown>;
8
+ outputNames: string[];
9
+ }
10
+ export type ScriptBackend = (job: ScriptJob, signal: AbortSignal) => Promise<unknown>;
11
+ /** Also runs inside the Worker, before structured cloning can erase invalid values. */
12
+ export declare function scriptJsonIssue(value: unknown, depth?: number, seen?: Set<object>): string | undefined;
13
+ export declare function scriptResultIssue(value: unknown, outputNames: readonly string[]): string | undefined;
14
+ export declare const SCRIPT_WORKER_SOURCE = "const MAX_SCRIPT_JSON_SIZE = 100000;\nfunction scriptJsonIssue(value, depth = 0, seen = new Set()) {\n if (depth > 10)\n return 'JSON nesting exceeds 10 levels.';\n if (value === null || typeof value === 'string' || typeof value === 'boolean')\n return;\n if (typeof value === 'number')\n return Number.isFinite(value) ? undefined : 'Non-finite numbers are not JSON.';\n if (typeof value !== 'object')\n return 'Value is not JSON-compatible.';\n if (seen.has(value))\n return 'Cyclic values are not JSON.';\n const prototype = Object.getPrototypeOf(value);\n if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null)\n return 'Only plain JSON objects are supported.';\n if (Object.getOwnPropertySymbols(value).length)\n return 'Symbol properties are not JSON.';\n const descriptors = Object.getOwnPropertyDescriptors(value);\n if (Object.values(descriptors).some(d => d.get || d.set))\n return 'Accessors are not JSON values.';\n const keys = Object.keys(value);\n if (keys.length > 1_000 || (Array.isArray(value) && value.length > 1_000))\n return 'JSON collection exceeds 1000 items.';\n if (Array.isArray(value) && keys.length !== value.length)\n return 'Sparse or extended arrays are not JSON.';\n seen.add(value);\n for (const key of keys) {\n const issue = scriptJsonIssue(value[key], depth + 1, seen);\n if (issue)\n return issue;\n }\n seen.delete(value);\n}\nfunction scriptResultIssue(value, outputNames) {\n const issue = scriptJsonIssue(value);\n if (issue)\n return issue;\n if (!value || typeof value !== 'object' || Array.isArray(value))\n return 'Script must return an object of named results.';\n if (JSON.stringify(value).length > MAX_SCRIPT_JSON_SIZE)\n return 'Script result exceeds 100000 characters.';\n const missing = outputNames.filter(name => !Object.hasOwn(value, name));\n return missing.length ? `Missing declared script outputs: ${missing.join(', ')}.` : undefined;\n}\nfunction freezeArguments(value) {\n if (value && typeof value === 'object') {\n for (const child of Object.values(value)) freezeArguments(child);\n Object.freeze(value);\n }\n return value;\n}\nself.onmessage = event => {\n try {\n const {source, inputs, outputNames} = event.data;\n const run = new Function(source + \"\\nreturn run;\")();\n if (typeof run !== 'function') throw new Error('Script must define function run(args).');\n const result = run(freezeArguments(inputs));\n const issue = scriptResultIssue(result, outputNames);\n if (issue) throw {code:'SCRIPT_INVALID_OUTPUT', message:issue};\n self.postMessage({ok:true, result});\n } catch (error) { self.postMessage({ok:false, error:String(error?.message || error), code:error?.code || 'SCRIPT_FAILED'}); }\n};";
15
+ /** Web Workers isolate termination; this is not a security sandbox. */
16
+ export declare const executeWebWorkerScript: ScriptBackend;
17
+ export declare class ScriptActionExecutor implements ActionExecutor {
18
+ private readonly backend?;
19
+ constructor(backend?: ScriptBackend | undefined);
20
+ supports(action: string): boolean;
21
+ execute(context: ActionExecutionContext): Promise<{
22
+ outputs: Record<string, unknown>;
23
+ }>;
24
+ }
package/dist/script.js ADDED
@@ -0,0 +1,157 @@
1
+ export const SCRIPT_TIMEOUT_MS = 1_000;
2
+ export const MAX_SCRIPT_SOURCE_LENGTH = 20_000;
3
+ export const MAX_SCRIPT_JSON_SIZE = 100_000;
4
+ /** Also runs inside the Worker, before structured cloning can erase invalid values. */
5
+ export function scriptJsonIssue(value, depth = 0, seen = new Set()) {
6
+ if (depth > 10)
7
+ return 'JSON nesting exceeds 10 levels.';
8
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
9
+ return;
10
+ if (typeof value === 'number')
11
+ return Number.isFinite(value) ? undefined : 'Non-finite numbers are not JSON.';
12
+ if (typeof value !== 'object')
13
+ return 'Value is not JSON-compatible.';
14
+ if (seen.has(value))
15
+ return 'Cyclic values are not JSON.';
16
+ const prototype = Object.getPrototypeOf(value);
17
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null)
18
+ return 'Only plain JSON objects are supported.';
19
+ if (Object.getOwnPropertySymbols(value).length)
20
+ return 'Symbol properties are not JSON.';
21
+ const descriptors = Object.getOwnPropertyDescriptors(value);
22
+ if (Object.values(descriptors).some(d => d.get || d.set))
23
+ return 'Accessors are not JSON values.';
24
+ const keys = Object.keys(value);
25
+ if (keys.length > 1_000 || (Array.isArray(value) && value.length > 1_000))
26
+ return 'JSON collection exceeds 1000 items.';
27
+ if (Array.isArray(value) && keys.length !== value.length)
28
+ return 'Sparse or extended arrays are not JSON.';
29
+ seen.add(value);
30
+ for (const key of keys) {
31
+ const issue = scriptJsonIssue(value[key], depth + 1, seen);
32
+ if (issue)
33
+ return issue;
34
+ }
35
+ seen.delete(value);
36
+ }
37
+ export function scriptResultIssue(value, outputNames) {
38
+ const issue = scriptJsonIssue(value);
39
+ if (issue)
40
+ return issue;
41
+ if (!value || typeof value !== 'object' || Array.isArray(value))
42
+ return 'Script must return an object of named results.';
43
+ if (JSON.stringify(value).length > MAX_SCRIPT_JSON_SIZE)
44
+ return 'Script result exceeds 100000 characters.';
45
+ const missing = outputNames.filter(name => !Object.hasOwn(value, name));
46
+ return missing.length ? `Missing declared script outputs: ${missing.join(', ')}.` : undefined;
47
+ }
48
+ function failure(code, message) { return { code, message }; }
49
+ // Self-contained literal: bundlers cannot rename identifiers inside this program.
50
+ export const SCRIPT_WORKER_SOURCE = "const MAX_SCRIPT_JSON_SIZE = 100000;\nfunction scriptJsonIssue(value, depth = 0, seen = new Set()) {\n if (depth > 10)\n return 'JSON nesting exceeds 10 levels.';\n if (value === null || typeof value === 'string' || typeof value === 'boolean')\n return;\n if (typeof value === 'number')\n return Number.isFinite(value) ? undefined : 'Non-finite numbers are not JSON.';\n if (typeof value !== 'object')\n return 'Value is not JSON-compatible.';\n if (seen.has(value))\n return 'Cyclic values are not JSON.';\n const prototype = Object.getPrototypeOf(value);\n if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null)\n return 'Only plain JSON objects are supported.';\n if (Object.getOwnPropertySymbols(value).length)\n return 'Symbol properties are not JSON.';\n const descriptors = Object.getOwnPropertyDescriptors(value);\n if (Object.values(descriptors).some(d => d.get || d.set))\n return 'Accessors are not JSON values.';\n const keys = Object.keys(value);\n if (keys.length > 1_000 || (Array.isArray(value) && value.length > 1_000))\n return 'JSON collection exceeds 1000 items.';\n if (Array.isArray(value) && keys.length !== value.length)\n return 'Sparse or extended arrays are not JSON.';\n seen.add(value);\n for (const key of keys) {\n const issue = scriptJsonIssue(value[key], depth + 1, seen);\n if (issue)\n return issue;\n }\n seen.delete(value);\n}\nfunction scriptResultIssue(value, outputNames) {\n const issue = scriptJsonIssue(value);\n if (issue)\n return issue;\n if (!value || typeof value !== 'object' || Array.isArray(value))\n return 'Script must return an object of named results.';\n if (JSON.stringify(value).length > MAX_SCRIPT_JSON_SIZE)\n return 'Script result exceeds 100000 characters.';\n const missing = outputNames.filter(name => !Object.hasOwn(value, name));\n return missing.length ? `Missing declared script outputs: ${missing.join(', ')}.` : undefined;\n}\nfunction freezeArguments(value) {\n if (value && typeof value === 'object') {\n for (const child of Object.values(value)) freezeArguments(child);\n Object.freeze(value);\n }\n return value;\n}\nself.onmessage = event => {\n try {\n const {source, inputs, outputNames} = event.data;\n const run = new Function(source + \"\\nreturn run;\")();\n if (typeof run !== 'function') throw new Error('Script must define function run(args).');\n const result = run(freezeArguments(inputs));\n const issue = scriptResultIssue(result, outputNames);\n if (issue) throw {code:'SCRIPT_INVALID_OUTPUT', message:issue};\n self.postMessage({ok:true, result});\n } catch (error) { self.postMessage({ok:false, error:String(error?.message || error), code:error?.code || 'SCRIPT_FAILED'}); }\n};";
51
+ /** Web Workers isolate termination; this is not a security sandbox. */
52
+ export const executeWebWorkerScript = (job, signal) => new Promise((resolve, reject) => {
53
+ if (signal.aborted) {
54
+ reject(failure('ACTION_CANCELLED', 'Script cancelled.'));
55
+ return;
56
+ }
57
+ if (typeof Worker === 'undefined') {
58
+ reject(failure('UNSUPPORTED_SCRIPT_EXECUTION', 'Web Worker backend unavailable.'));
59
+ return;
60
+ }
61
+ let worker;
62
+ let url;
63
+ let settled = false;
64
+ const finish = (error, value) => {
65
+ if (settled)
66
+ return;
67
+ settled = true;
68
+ signal.removeEventListener('abort', abort);
69
+ worker?.terminate();
70
+ if (url)
71
+ URL.revokeObjectURL(url);
72
+ if (error)
73
+ reject(error);
74
+ else
75
+ resolve(value);
76
+ };
77
+ const abort = () => finish(failure('ACTION_CANCELLED', 'Script cancelled.'));
78
+ signal.addEventListener('abort', abort, { once: true });
79
+ try {
80
+ url = URL.createObjectURL(new Blob([SCRIPT_WORKER_SOURCE], { type: 'text/javascript' }));
81
+ worker = new Worker(url);
82
+ worker.onmessage = event => event.data?.ok ? finish(undefined, event.data.result) : finish(failure(event.data?.code || 'SCRIPT_FAILED', event.data?.error || 'Script failed.'));
83
+ worker.onerror = event => finish(failure('SCRIPT_FAILED', event.message || 'Script worker failed.'));
84
+ worker.postMessage(job);
85
+ }
86
+ catch (error) {
87
+ finish(error);
88
+ }
89
+ });
90
+ export class ScriptActionExecutor {
91
+ backend;
92
+ constructor(backend) {
93
+ this.backend = backend;
94
+ }
95
+ supports(action) { return action.startsWith('script.'); }
96
+ execute(context) {
97
+ // Deadline includes validation, backend startup, dispatch and result validation.
98
+ const accepted = Date.now();
99
+ return new Promise((resolve, reject) => {
100
+ const controller = new AbortController();
101
+ let settled = false;
102
+ const finish = (error, value) => {
103
+ if (settled)
104
+ return;
105
+ settled = true;
106
+ clearTimeout(timer);
107
+ context.signal.removeEventListener('abort', abort);
108
+ controller.abort();
109
+ if (error)
110
+ reject(error);
111
+ else
112
+ resolve({ outputs: value });
113
+ };
114
+ const abort = () => finish(failure('ACTION_CANCELLED', 'Script cancelled by execution session.'));
115
+ const timer = setTimeout(() => finish(failure('SCRIPT_TIMEOUT', 'Script exceeded 1000 ms execution limit.')), SCRIPT_TIMEOUT_MS);
116
+ context.signal.addEventListener('abort', abort, { once: true });
117
+ if (context.signal.aborted) {
118
+ abort();
119
+ return;
120
+ }
121
+ const config = context.step.adapterConfig;
122
+ const source = config?.source;
123
+ const names = config?.outputNames;
124
+ if (typeof source !== 'string' || source.length > MAX_SCRIPT_SOURCE_LENGTH || !/\bfunction\s+run\s*\(/.test(source) || !Array.isArray(names) || !names.every(name => typeof name === 'string')) {
125
+ finish(failure('SCRIPT_INVALID_SOURCE', 'Script must define run(args), declare output names, and be at most 20000 characters.'));
126
+ return;
127
+ }
128
+ const issue = scriptJsonIssue(context.inputs);
129
+ if (issue) {
130
+ finish(failure('SCRIPT_INVALID_INPUT', issue));
131
+ return;
132
+ }
133
+ if (!this.backend) {
134
+ finish(failure('UNSUPPORTED_SCRIPT_EXECUTION', 'No script execution backend registered.'));
135
+ return;
136
+ }
137
+ const job = { source, outputNames: [...names], inputs: structuredClone(context.inputs) };
138
+ Promise.resolve().then(() => this.backend(job, controller.signal)).then(value => {
139
+ if (settled)
140
+ return;
141
+ const issue = scriptResultIssue(value, job.outputNames);
142
+ if (context.signal.aborted) {
143
+ abort();
144
+ return;
145
+ }
146
+ if (Date.now() - accepted >= SCRIPT_TIMEOUT_MS) {
147
+ finish(failure('SCRIPT_TIMEOUT', 'Script exceeded 1000 ms execution limit.'));
148
+ return;
149
+ }
150
+ if (issue)
151
+ finish(failure('SCRIPT_INVALID_OUTPUT', issue));
152
+ else
153
+ finish(undefined, structuredClone(value));
154
+ }, error => finish(failure(typeof error?.code === 'string' ? error.code : 'SCRIPT_FAILED', typeof error?.message === 'string' ? error.message : String(error))));
155
+ });
156
+ }
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qaick/runtime",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Host-neutral composition layer for QAick execution.",
5
5
  "type": "module",
6
6
  "files": [