@nanobpm/urban 0.28.0 → 0.29.0
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/runtime/adapters/deno.js +5 -0
- package/dist/runtime/adapters/node.js +5 -0
- package/dist/runtime/core/execContext.d.ts +28 -0
- package/dist/runtime/core/execContext.js +39 -0
- package/dist/runtime/core/host.d.ts +41 -0
- package/dist/runtime/core/manifest.d.ts +1 -1
- package/dist/runtime/core/modules/connectors.js +3 -1
- package/dist/runtime/core/modules/datasource.d.ts +18 -1
- package/dist/runtime/core/modules/datasource.js +56 -2
- package/dist/runtime/core/modules/gateway.d.ts +10 -3
- package/dist/runtime/core/modules/gateway.js +23 -6
- package/dist/runtime/core/modules/instance-tracking.d.ts +14 -0
- package/dist/runtime/core/modules/instance-tracking.js +141 -0
- package/dist/runtime/core/modules/scheduler.d.ts +11 -0
- package/dist/runtime/core/modules/scheduler.js +21 -0
- package/dist/runtime/core/modules/triggers.d.ts +2 -8
- package/dist/runtime/core/modules/triggers.js +1 -16
- package/dist/runtime/core/modules/workers.js +3 -2
- package/dist/runtime/core/runtime.d.ts +3 -0
- package/dist/runtime/core/runtime.js +12 -0
- package/dist/runtime/core/validate.js +38 -0
- package/dist/runtime/engine/nanosdk.d.ts +14 -1
- package/dist/runtime/engine/nanosdk.js +49 -0
- package/package.json +2 -2
- package/src/runtime/adapters/deno.ts +6 -0
- package/src/runtime/adapters/node.ts +6 -0
- package/src/runtime/core/execContext.ts +56 -0
- package/src/runtime/core/host.ts +44 -0
- package/src/runtime/core/manifest.ts +1 -0
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/connectors.ts +7 -1
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/datasource.ts +72 -2
- package/src/runtime/core/modules/gateway.ts +28 -6
- package/src/runtime/core/modules/instance-tracking.test.ts +345 -0
- package/src/runtime/core/modules/instance-tracking.ts +158 -0
- package/src/runtime/core/modules/pages.test.ts +1 -0
- package/src/runtime/core/modules/provenance.test.ts +181 -0
- package/src/runtime/core/modules/scheduler.ts +31 -0
- package/src/runtime/core/modules/surfaces.test.ts +2 -0
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/triggers.ts +3 -25
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/modules/workers.ts +11 -2
- package/src/runtime/core/runtime.test.ts +3 -0
- package/src/runtime/core/runtime.ts +16 -0
- package/src/runtime/core/validate.test.ts +96 -0
- package/src/runtime/core/validate.ts +39 -0
- package/src/runtime/engine/nanosdk.test.ts +73 -0
- package/src/runtime/engine/nanosdk.ts +63 -0
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { isRecord } from "../guards.js";
|
|
6
6
|
import { json, normalizeRoutePath } from "../router.js";
|
|
7
7
|
import { nextCronFire, parseCron } from "./cron.js";
|
|
8
|
+
import { defaultScheduler, MAX_TIMER_DELAY_MS } from "./scheduler.js";
|
|
8
9
|
/**
|
|
9
10
|
* Resolve a tiny FEEL-ish expression against the event scope: a `= body.a.b` path walk
|
|
10
11
|
* returns the raw value at that path (or `undefined` if any segment is missing); a literal
|
|
@@ -92,22 +93,6 @@ export async function runTriggerAction(app, action, scope) {
|
|
|
92
93
|
return { kind: "none" };
|
|
93
94
|
}
|
|
94
95
|
const emptyScope = { body: {}, headers: {}, query: {} };
|
|
95
|
-
/** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
|
|
96
|
-
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
97
|
-
/** Default (live) scheduler seam, backed by the global timer functions. */
|
|
98
|
-
function defaultScheduler() {
|
|
99
|
-
return {
|
|
100
|
-
setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
101
|
-
clearTimer: (h) => {
|
|
102
|
-
if (isTimerHandle(h))
|
|
103
|
-
globalThis.clearTimeout(h);
|
|
104
|
-
},
|
|
105
|
-
now: () => Date.now(),
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
function isTimerHandle(handle) {
|
|
109
|
-
return typeof handle === "number" || (typeof handle === "object" && handle !== null);
|
|
110
|
-
}
|
|
111
96
|
/** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
|
|
112
97
|
* background timers that fire their action on schedule. `stop()` clears every timer. */
|
|
113
98
|
export function mountTriggers(ctx, app, sched = defaultScheduler()) {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { isRecord } from "../guards.js";
|
|
5
5
|
import { workerJobType } from "../manifest.js";
|
|
6
6
|
import { runLlmJob } from "./llm.js";
|
|
7
|
+
import { runInJobContext } from "../execContext.js";
|
|
7
8
|
/** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
|
|
8
9
|
* decision's JSON-string `output` into the value fed back to the LLM job. Validates the
|
|
9
10
|
* SDK shape and adds decision context to a parse failure rather than surfacing a raw
|
|
@@ -98,7 +99,7 @@ export async function mountWorkers(ctx, app) {
|
|
|
98
99
|
env: (n) => app.env(n),
|
|
99
100
|
evaluateDecision: app.sdk ? sdkDecisionEvaluator(app.sdk) : undefined,
|
|
100
101
|
};
|
|
101
|
-
const wrapped = (job) => runLlmJob(job.variables, binding, rt);
|
|
102
|
+
const wrapped = (job) => runInJobContext({ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType }, () => runLlmJob(job.variables, binding, rt));
|
|
102
103
|
const sub = await ctx.engine.registerWorker(jobType, wrapped, {
|
|
103
104
|
workerName: `${ctx.manifest.id}:${jobType}`,
|
|
104
105
|
});
|
|
@@ -113,7 +114,7 @@ export async function mountWorkers(ctx, app) {
|
|
|
113
114
|
throw new Error(`worker "${jobType}": ${decl.handler} exports no handler for it ` +
|
|
114
115
|
`(expected handlers["${jobType}"], a named export, or a default function)`);
|
|
115
116
|
}
|
|
116
|
-
const wrapped = (job) => handler(job, app);
|
|
117
|
+
const wrapped = (job) => runInJobContext({ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType }, () => handler(job, app));
|
|
117
118
|
const sub = await ctx.engine.registerWorker(jobType, wrapped, {
|
|
118
119
|
workerName: `${ctx.manifest.id}:${jobType}`,
|
|
119
120
|
});
|
|
@@ -13,6 +13,9 @@ export interface MountFlags {
|
|
|
13
13
|
surfaces?: boolean;
|
|
14
14
|
triggers?: boolean;
|
|
15
15
|
security?: boolean;
|
|
16
|
+
/** The `instanceTracking` reconciler poll loop. Shares no HTTP surface; safe to disable
|
|
17
|
+
* in hosts that only render surfaces (e.g. the console preview). */
|
|
18
|
+
instanceTracking?: boolean;
|
|
16
19
|
}
|
|
17
20
|
export interface CreateUrbanAppOptions {
|
|
18
21
|
host: HostContext;
|
|
@@ -7,10 +7,12 @@ import { validateManifest } from "./validate.js";
|
|
|
7
7
|
import { makeRouter } from "./router.js";
|
|
8
8
|
import { deployModels } from "./modules/deploy.js";
|
|
9
9
|
import { provisionData, DataLayer } from "./modules/datasource.js";
|
|
10
|
+
import { installExecStore } from "./execContext.js";
|
|
10
11
|
import { mountWorkers } from "./modules/workers.js";
|
|
11
12
|
import { mountConnectors } from "./modules/connectors.js";
|
|
12
13
|
import { mountSurfaces } from "./modules/surfaces.js";
|
|
13
14
|
import { mountTriggers } from "./modules/triggers.js";
|
|
15
|
+
import { mountInstanceTracking } from "./modules/instance-tracking.js";
|
|
14
16
|
import { mountSecurity } from "./modules/security.js";
|
|
15
17
|
/** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
|
|
16
18
|
* error when $PORT is set but not a valid integer in 0..65535. */
|
|
@@ -43,8 +45,13 @@ export async function createUrbanApp(opts) {
|
|
|
43
45
|
surfaces: opts.mount?.surfaces ?? true,
|
|
44
46
|
triggers: opts.mount?.triggers ?? true,
|
|
45
47
|
security: opts.mount?.security ?? true,
|
|
48
|
+
instanceTracking: opts.mount?.instanceTracking ?? true,
|
|
46
49
|
};
|
|
47
50
|
const ctx = { manifest, host, engine, root, templates: opts.templates };
|
|
51
|
+
// Install the ambient job-execution store once per process (idempotent) so worker dispatch can
|
|
52
|
+
// stamp write-provenance onto DataLayer inserts. Absent-safe: a host without `createAsyncStore`
|
|
53
|
+
// leaves provenance capture disabled.
|
|
54
|
+
installExecStore(() => host.createAsyncStore?.());
|
|
48
55
|
let data;
|
|
49
56
|
let security;
|
|
50
57
|
let server;
|
|
@@ -144,6 +151,11 @@ export async function createUrbanApp(opts) {
|
|
|
144
151
|
mounted.push(t);
|
|
145
152
|
describe.triggers = t.describe?.();
|
|
146
153
|
}
|
|
154
|
+
if (flags.instanceTracking && (manifest.instanceTracking?.length ?? 0) > 0) {
|
|
155
|
+
const it = mountInstanceTracking(ctx, api);
|
|
156
|
+
mounted.push(it);
|
|
157
|
+
describe.instanceTracking = it.describe?.();
|
|
158
|
+
}
|
|
147
159
|
if (security)
|
|
148
160
|
describe.security = security.describe();
|
|
149
161
|
if (data)
|
|
@@ -131,6 +131,44 @@ export function collectManifestIssues(m) {
|
|
|
131
131
|
issues.push({ path: `triggers[${i}].type`, message: "missing type" });
|
|
132
132
|
});
|
|
133
133
|
}
|
|
134
|
+
// instanceTracking: each binding must name the table + key column it tracks and a
|
|
135
|
+
// non-empty `onTerminated.set` patch — a reconciler with nothing to write is inert
|
|
136
|
+
// and almost certainly a mistake. `activeStatuses` without `statusField` is incoherent
|
|
137
|
+
// (no column to read the statuses from), so the runtime would silently poll every row.
|
|
138
|
+
const tracking = Array.isArray(obj.instanceTracking) ? obj.instanceTracking : undefined;
|
|
139
|
+
if (tracking) {
|
|
140
|
+
tracking.forEach((t, i) => {
|
|
141
|
+
const b = isRecord(t) ? t : undefined;
|
|
142
|
+
if (typeof b?.table !== "string" || b.table.length === 0) {
|
|
143
|
+
issues.push({ path: `instanceTracking[${i}].table`, message: "missing table" });
|
|
144
|
+
}
|
|
145
|
+
if (typeof b?.keyField !== "string" || b.keyField.length === 0) {
|
|
146
|
+
issues.push({ path: `instanceTracking[${i}].keyField`, message: "missing keyField" });
|
|
147
|
+
}
|
|
148
|
+
const onTerminated = isRecord(b?.onTerminated) ? b.onTerminated : undefined;
|
|
149
|
+
const set = isRecord(onTerminated?.set) ? onTerminated.set : undefined;
|
|
150
|
+
if (!set || Object.keys(set).length === 0) {
|
|
151
|
+
issues.push({
|
|
152
|
+
path: `instanceTracking[${i}].onTerminated.set`,
|
|
153
|
+
message: "missing onTerminated.set patch (a non-empty column → value map)",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (Array.isArray(b?.activeStatuses) && (typeof b?.statusField !== "string" || b.statusField.length === 0)) {
|
|
157
|
+
issues.push({
|
|
158
|
+
path: `instanceTracking[${i}].activeStatuses`,
|
|
159
|
+
message: "activeStatuses requires statusField",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// `pollMs`, when set, schedules a self-rescheduling timer; a non-number/NaN/non-positive
|
|
163
|
+
// value would become a 0-delay hot loop at runtime, so reject it at author time.
|
|
164
|
+
if (b?.pollMs !== undefined && (typeof b.pollMs !== "number" || !Number.isFinite(b.pollMs) || b.pollMs <= 0)) {
|
|
165
|
+
issues.push({
|
|
166
|
+
path: `instanceTracking[${i}].pollMs`,
|
|
167
|
+
message: "pollMs must be a finite positive number of milliseconds",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
134
172
|
return issues;
|
|
135
173
|
}
|
|
136
174
|
export function assertValidManifest(m) {
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import type { EngineClient, JobHandler, WorkerSubscription } from "../core/host.ts";
|
|
1
|
+
import type { EngineClient, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, WorkerSubscription } from "../core/host.ts";
|
|
2
2
|
import type { EngineSdkClient } from "./sdk.ts";
|
|
3
3
|
/** Coerce an engine response's process-instance key to a non-empty string, or
|
|
4
4
|
* throw — a missing key means a malformed/partial response, not a real instance. */
|
|
5
5
|
export declare function requireProcessInstanceKey(key: string | number | null | undefined): string;
|
|
6
|
+
/** Map the engine's process-instance `state` onto the transport-agnostic
|
|
7
|
+
* {@link ProcessInstanceState} union, or `undefined` for an unrecognized value
|
|
8
|
+
* (which the caller skips rather than mis-reconciling). Case-insensitive so a
|
|
9
|
+
* future casing tweak on the engine side doesn't silently drop instances. */
|
|
10
|
+
export declare function normalizeProcessInstanceState(raw: unknown): ProcessInstanceState | undefined;
|
|
6
11
|
/** A job as delivered to a nano-sdk job handler: the frame fields plus the
|
|
7
12
|
* acknowledgement actions the handler must call. */
|
|
8
13
|
export interface NanoSdkActivatedJob {
|
|
@@ -76,6 +81,10 @@ export interface NanoSdkClient {
|
|
|
76
81
|
userTaskKey: string;
|
|
77
82
|
variables?: Record<string, unknown>;
|
|
78
83
|
}, options?: unknown): Promise<unknown>;
|
|
84
|
+
searchProcessInstances(input: {
|
|
85
|
+
filter?: Record<string, unknown>;
|
|
86
|
+
page?: Record<string, unknown>;
|
|
87
|
+
}, consistency?: unknown, options?: unknown): Promise<Record<string, unknown>>;
|
|
79
88
|
createJobWorker(cfg: NanoSdkJobWorkerConfig): NanoSdkJobWorker;
|
|
80
89
|
/** Stop every worker created on this client. Used on teardown to also drain a
|
|
81
90
|
* REST-fallback worker, whose handle the `createJobWorker` proxy starts internally
|
|
@@ -134,6 +143,10 @@ export declare class SdkEngineClient implements EngineClient {
|
|
|
134
143
|
variables?: Record<string, unknown>;
|
|
135
144
|
}[]>;
|
|
136
145
|
completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
|
|
146
|
+
searchProcessInstances(filter?: {
|
|
147
|
+
processInstanceKeys?: string[];
|
|
148
|
+
state?: ProcessInstanceState;
|
|
149
|
+
}): Promise<ProcessInstanceSnapshot[]>;
|
|
137
150
|
registerWorker(jobType: string, handler: JobHandler, options?: {
|
|
138
151
|
workerName?: string;
|
|
139
152
|
maxParallelJobs?: number;
|
|
@@ -28,6 +28,24 @@ export function requireProcessInstanceKey(key) {
|
|
|
28
28
|
}
|
|
29
29
|
return String(key);
|
|
30
30
|
}
|
|
31
|
+
/** Map the engine's process-instance `state` onto the transport-agnostic
|
|
32
|
+
* {@link ProcessInstanceState} union, or `undefined` for an unrecognized value
|
|
33
|
+
* (which the caller skips rather than mis-reconciling). Case-insensitive so a
|
|
34
|
+
* future casing tweak on the engine side doesn't silently drop instances. */
|
|
35
|
+
export function normalizeProcessInstanceState(raw) {
|
|
36
|
+
if (typeof raw !== "string")
|
|
37
|
+
return undefined;
|
|
38
|
+
switch (raw.toUpperCase()) {
|
|
39
|
+
case "ACTIVE":
|
|
40
|
+
return "ACTIVE";
|
|
41
|
+
case "COMPLETED":
|
|
42
|
+
return "COMPLETED";
|
|
43
|
+
case "TERMINATED":
|
|
44
|
+
return "TERMINATED";
|
|
45
|
+
default:
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
31
49
|
/**
|
|
32
50
|
* An `EngineClient` backed entirely by a `@nanobpm/nano-sdk` client. Deploy, create,
|
|
33
51
|
* message, and user-task calls map one-to-one onto the SDK's client methods; workers
|
|
@@ -101,6 +119,37 @@ export class SdkEngineClient {
|
|
|
101
119
|
async completeUserTask(userTaskKey, variables) {
|
|
102
120
|
await this.client.completeUserTask({ userTaskKey, variables: variables ?? {} });
|
|
103
121
|
}
|
|
122
|
+
async searchProcessInstances(filter) {
|
|
123
|
+
const f = {};
|
|
124
|
+
if (filter?.state)
|
|
125
|
+
f.state = filter.state;
|
|
126
|
+
const keys = filter?.processInstanceKeys?.filter((k) => k != null && k !== "");
|
|
127
|
+
if (keys && keys.length > 0)
|
|
128
|
+
f.processInstanceKey = { $in: keys };
|
|
129
|
+
// A process-instance search is an eventually consistent read; ask for zero-wait
|
|
130
|
+
// consistency so it reflects what is currently visible without blocking. Cap the page
|
|
131
|
+
// to the number of keys asked for (each key matches at most one instance), so a bounded
|
|
132
|
+
// reconcile query never silently truncates on the API's default page size.
|
|
133
|
+
const page = keys && keys.length > 0 ? { limit: keys.length } : undefined;
|
|
134
|
+
const body = await this.client.searchProcessInstances({ filter: f, ...(page ? { page } : {}) }, { consistency: { waitUpToMs: 0 } });
|
|
135
|
+
const items = Array.isArray(body.items) ? body.items.filter(isRecord) : [];
|
|
136
|
+
return items.flatMap((it) => {
|
|
137
|
+
const key = it.processInstanceKey;
|
|
138
|
+
if (key == null || key === "") {
|
|
139
|
+
this.log("warn", "skipping process instance with no key in engine response");
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
const state = normalizeProcessInstanceState(it.state);
|
|
143
|
+
if (!state) {
|
|
144
|
+
this.log("warn", "skipping process instance with unrecognized state", {
|
|
145
|
+
processInstanceKey: String(key),
|
|
146
|
+
state: String(it.state),
|
|
147
|
+
});
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
return [{ processInstanceKey: String(key), state }];
|
|
151
|
+
});
|
|
152
|
+
}
|
|
104
153
|
async registerWorker(jobType, handler, options) {
|
|
105
154
|
const worker = this.client.createJobWorker({
|
|
106
155
|
jobType,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"test:deno": "deno test -A --no-check src/"
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@nanobpm/nano-app-schema": "^0.
|
|
65
|
+
"@nanobpm/nano-app-schema": "^0.4.0",
|
|
66
66
|
"@nanobpm/nano-sdk": "^1.2.3",
|
|
67
67
|
"@nanobpm/workflow": "^0.8.0",
|
|
68
68
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
import { isAbsolute, resolve } from "node:path";
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
8
|
import { DatabaseSync } from "node:sqlite";
|
|
9
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
9
10
|
import type {
|
|
11
|
+
AsyncStore,
|
|
10
12
|
HostContext,
|
|
11
13
|
HttpHandler,
|
|
12
14
|
HttpRequest,
|
|
@@ -134,6 +136,10 @@ export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
|
|
|
134
136
|
};
|
|
135
137
|
},
|
|
136
138
|
now: () => Date.now(),
|
|
139
|
+
createAsyncStore<T>(): AsyncStore<T> {
|
|
140
|
+
const als = new AsyncLocalStorage<T>();
|
|
141
|
+
return { run: <R>(value: T, fn: () => R): R => als.run(value, fn), current: () => als.getStore() };
|
|
142
|
+
},
|
|
137
143
|
log,
|
|
138
144
|
};
|
|
139
145
|
}
|
|
@@ -9,7 +9,9 @@ import { register } from "node:module";
|
|
|
9
9
|
import { isAbsolute, resolve } from "node:path";
|
|
10
10
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
11
|
import { DatabaseSync } from "node:sqlite";
|
|
12
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12
13
|
import type {
|
|
14
|
+
AsyncStore,
|
|
13
15
|
HostContext,
|
|
14
16
|
HttpHandler,
|
|
15
17
|
HttpRequest,
|
|
@@ -176,6 +178,10 @@ export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
|
|
|
176
178
|
return { close: () => w.close() } satisfies WatchHandle;
|
|
177
179
|
},
|
|
178
180
|
now: () => Date.now(),
|
|
181
|
+
createAsyncStore<T>(): AsyncStore<T> {
|
|
182
|
+
const als = new AsyncLocalStorage<T>();
|
|
183
|
+
return { run: <R>(value: T, fn: () => R): R => als.run(value, fn), current: () => als.getStore() };
|
|
184
|
+
},
|
|
179
185
|
log,
|
|
180
186
|
};
|
|
181
187
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Ambient job execution context — threads the active job's process instance / element through
|
|
2
|
+
// to DataLayer writes so write-provenance capture (ProcessOS domain-signal plane, D0) can join
|
|
3
|
+
// written rows back to the instance/element that wrote them, WITHOUT plumbing the job through
|
|
4
|
+
// every call. Entirely domain-free: it knows nothing about any app's tables or their meaning.
|
|
5
|
+
//
|
|
6
|
+
// The async primitive (`AsyncLocalStorage`) is a host capability (`node:async_hooks`), so it is
|
|
7
|
+
// injected through the `HostContext` seam; core stays `node:*`-free per the host contract. When
|
|
8
|
+
// no store is installed — a host that doesn't provide one, or a plain unit test — `run()` just
|
|
9
|
+
// calls `fn` and `current()` returns undefined, so capture degrades to a no-op. Absent-safe and
|
|
10
|
+
// zero-cost when off, by construction.
|
|
11
|
+
|
|
12
|
+
import type { AsyncStore } from "./host.ts";
|
|
13
|
+
|
|
14
|
+
/** The ambient context of the job currently executing on a worker. All fields are optional so a
|
|
15
|
+
* job dispatched without an instance/element still runs; provenance is only recorded when an
|
|
16
|
+
* `instanceKey` is present. */
|
|
17
|
+
export interface JobExecContext {
|
|
18
|
+
/** The process instance that dispatched the running job. */
|
|
19
|
+
instanceKey?: string;
|
|
20
|
+
/** The BPMN element (e.g. service task) the job is for. */
|
|
21
|
+
elementId?: string;
|
|
22
|
+
/** The job type (worker) currently executing. */
|
|
23
|
+
jobType?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let store: AsyncStore<JobExecContext> | undefined;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Install the host-backed ambient store once per process. Idempotent — the first store created
|
|
30
|
+
* wins: every Node/Deno store is an equivalent `AsyncLocalStorage` instance, and a process that
|
|
31
|
+
* hosts several apps shares one async store safely (values are keyed by async execution context,
|
|
32
|
+
* not by app). A `factory` that returns undefined (a host without the capability) leaves capture
|
|
33
|
+
* disabled, but a later capable host may still install the process store.
|
|
34
|
+
*/
|
|
35
|
+
export function installExecStore(factory: () => AsyncStore<JobExecContext> | undefined): void {
|
|
36
|
+
if (store) return;
|
|
37
|
+
const next = factory();
|
|
38
|
+
if (next) store = next;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Run `fn` with `ctx` as the ambient job context. A transparent pass-through when no store is
|
|
42
|
+
* installed (returns `fn()` directly). */
|
|
43
|
+
export function runInJobContext<R>(ctx: JobExecContext, fn: () => R): R {
|
|
44
|
+
return store ? store.run(ctx, fn) : fn();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The ambient job context of the current async execution, or undefined outside a job (or when
|
|
48
|
+
* no store is installed). */
|
|
49
|
+
export function currentJobContext(): JobExecContext | undefined {
|
|
50
|
+
return store?.current();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Test seam: drop the installed store so a subsequent {@link installExecStore} re-installs. */
|
|
54
|
+
export function __resetExecStoreForTests(): void {
|
|
55
|
+
store = undefined;
|
|
56
|
+
}
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -35,6 +35,17 @@ export interface WatchHandle {
|
|
|
35
35
|
close(): void;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** A minimal ambient async-scoped store — the subset of `AsyncLocalStorage` the runtime needs
|
|
39
|
+
* to thread a job's execution context to DataLayer writes for write-provenance capture. Backed
|
|
40
|
+
* by `node:async_hooks` in both the Node and Deno adapters (both provide it); core depends only
|
|
41
|
+
* on this interface, never on `node:*`. */
|
|
42
|
+
export interface AsyncStore<T> {
|
|
43
|
+
/** Run `fn` with `value` as the current store value, restoring the prior value afterwards. */
|
|
44
|
+
run<R>(value: T, fn: () => R): R;
|
|
45
|
+
/** The current store value, or undefined outside any `run`. */
|
|
46
|
+
current(): T | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
/** A tiny synchronous SQLite handle — the subset the runtime needs. */
|
|
39
50
|
export interface SqliteDb {
|
|
40
51
|
/** Execute one or more statements with no result (DDL, PRAGMA, migrations). */
|
|
@@ -89,6 +100,12 @@ export interface HostContext {
|
|
|
89
100
|
watch?(onChange: (path: string) => void): WatchHandle;
|
|
90
101
|
/** Current wall-clock time in ms since epoch (seam for tests). */
|
|
91
102
|
now(): number;
|
|
103
|
+
/**
|
|
104
|
+
* Create an ambient async-scoped store (backed by `AsyncLocalStorage`). Used to thread the
|
|
105
|
+
* active job's execution context to DataLayer writes for write-provenance capture. Optional:
|
|
106
|
+
* a host that cannot provide one leaves provenance capture disabled (absent-safe).
|
|
107
|
+
*/
|
|
108
|
+
createAsyncStore?<T>(): AsyncStore<T>;
|
|
92
109
|
/** Structured log sink. */
|
|
93
110
|
log(level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
|
|
94
111
|
}
|
|
@@ -146,6 +163,21 @@ export interface WorkerSubscription {
|
|
|
146
163
|
unsubscribe(): Promise<void>;
|
|
147
164
|
}
|
|
148
165
|
|
|
166
|
+
/**
|
|
167
|
+
* A process instance's terminal-relevant lifecycle state, as the engine reports it.
|
|
168
|
+
* `ACTIVE` (still running), `COMPLETED` (ended normally at an end event), or
|
|
169
|
+
* `TERMINATED` (cancelled/terminated — via a row-cancel, an operator, or a crash).
|
|
170
|
+
* Deliberately the small set the instance-tracking reconciler keys on, not the richer
|
|
171
|
+
* element-instance state set (which adds SKIPPED/CANCELED/FAILED for tokens).
|
|
172
|
+
*/
|
|
173
|
+
export type ProcessInstanceState = "ACTIVE" | "COMPLETED" | "TERMINATED";
|
|
174
|
+
|
|
175
|
+
/** A single process instance's lifecycle snapshot returned by {@link EngineClient.searchProcessInstances}. */
|
|
176
|
+
export interface ProcessInstanceSnapshot {
|
|
177
|
+
readonly processInstanceKey: string;
|
|
178
|
+
readonly state: ProcessInstanceState;
|
|
179
|
+
}
|
|
180
|
+
|
|
149
181
|
/**
|
|
150
182
|
* EngineClient is the seam onto a Nano engine. The SDK/REST-backed adapter implements
|
|
151
183
|
* it against a live engine; tests implement it in-memory. Core modules depend only on this.
|
|
@@ -177,6 +209,18 @@ export interface EngineClient {
|
|
|
177
209
|
}): Promise<{ userTaskKey: string; elementId?: string; variables?: Record<string, unknown> }[]>;
|
|
178
210
|
/** Complete a user task. */
|
|
179
211
|
completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
|
|
212
|
+
/**
|
|
213
|
+
* Search process instances by key and/or lifecycle state. The instance-tracking
|
|
214
|
+
* reconciler uses this to detect a tracked instance reaching a terminal state
|
|
215
|
+
* (`TERMINATED`/`COMPLETED`) even though no completion worker ran for it — the
|
|
216
|
+
* read-model row it backs would otherwise stay "active" forever. An eventually
|
|
217
|
+
* consistent read (zero-wait); pass the keys currently marked active in the
|
|
218
|
+
* read model and, optionally, a `state` to narrow the result.
|
|
219
|
+
*/
|
|
220
|
+
searchProcessInstances(filter?: {
|
|
221
|
+
processInstanceKeys?: string[];
|
|
222
|
+
state?: ProcessInstanceState;
|
|
223
|
+
}): Promise<ProcessInstanceSnapshot[]>;
|
|
180
224
|
/** Register a push worker for a job type. Draining is handled by the adapter. */
|
|
181
225
|
registerWorker(
|
|
182
226
|
jobType: string,
|
|
@@ -29,6 +29,7 @@ const engine: EngineClient = {
|
|
|
29
29
|
publishMessage: async () => {},
|
|
30
30
|
searchUserTasks: async () => [],
|
|
31
31
|
completeUserTask: async () => {},
|
|
32
|
+
searchProcessInstances: async () => [],
|
|
32
33
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
33
34
|
close: async () => {},
|
|
34
35
|
};
|
|
@@ -17,6 +17,7 @@ class MiniEngine implements EngineClient {
|
|
|
17
17
|
async publishMessage(): Promise<void> {}
|
|
18
18
|
async searchUserTasks(): Promise<[]> { return []; }
|
|
19
19
|
async completeUserTask(): Promise<void> {}
|
|
20
|
+
async searchProcessInstances(): Promise<[]> { return []; }
|
|
20
21
|
async registerWorker(jobType: string, handler: JobHandler): Promise<WorkerSubscription> {
|
|
21
22
|
this.workers.set(jobType, handler);
|
|
22
23
|
return { jobType, unsubscribe: async () => void this.workers.delete(jobType) };
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type ConnectorWorkerJob,
|
|
25
25
|
type DefinedConnectorWorker,
|
|
26
26
|
} from "../../connector-worker-sdk.ts";
|
|
27
|
+
import { runInJobContext } from "../execContext.ts";
|
|
27
28
|
|
|
28
29
|
/** A config field a pack declares (subset of ext-types `ConfigField`): an env
|
|
29
30
|
* pointer with an optional default. */
|
|
@@ -244,7 +245,12 @@ export async function mountConnectors(ctx: RuntimeContext, app: AppApi): Promise
|
|
|
244
245
|
);
|
|
245
246
|
}
|
|
246
247
|
|
|
247
|
-
const
|
|
248
|
+
const inner = adaptConnectorHandler(worker);
|
|
249
|
+
const wrapped: JobHandler = (job) =>
|
|
250
|
+
runInJobContext(
|
|
251
|
+
{ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType },
|
|
252
|
+
() => inner(job),
|
|
253
|
+
);
|
|
248
254
|
const sub = await ctx.engine.registerWorker(jobType, wrapped, {
|
|
249
255
|
workerName: `${ctx.manifest.id}:${jobType}`,
|
|
250
256
|
// The pack's `defineWorker({ maxParallelJobs })` is the authoritative runtime
|
|
@@ -17,6 +17,7 @@ const noEngine: EngineClient = {
|
|
|
17
17
|
publishMessage: async () => {},
|
|
18
18
|
searchUserTasks: async () => [],
|
|
19
19
|
completeUserTask: async () => {},
|
|
20
|
+
searchProcessInstances: async () => [],
|
|
20
21
|
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
21
22
|
close: async () => {},
|
|
22
23
|
};
|
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
import type { RuntimeContext } from "../context.ts";
|
|
7
7
|
import type { HostContext, SqliteDb } from "../host.ts";
|
|
8
8
|
import type { AppManifest, DataSource, DomainType } from "../manifest.ts";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
makeGateway,
|
|
11
|
+
Table,
|
|
12
|
+
type DataSource as GatewayDataSource,
|
|
13
|
+
type InsertObserver,
|
|
14
|
+
} from "./gateway.ts";
|
|
15
|
+
import { currentJobContext } from "../execContext.ts";
|
|
10
16
|
|
|
11
17
|
export function sqlitePathFromUrl(url: string): string {
|
|
12
18
|
// Accept "file:./x.db", "file:x.db", "sqlite:./x.db" or a bare path.
|
|
@@ -51,6 +57,65 @@ export function resolveAppPath(root: string, p: string): string {
|
|
|
51
57
|
* both import this constant and the ledger name can never drift between application and listing. */
|
|
52
58
|
export const MIGRATIONS_TABLE = "_urban_migrations";
|
|
53
59
|
|
|
60
|
+
/** Name of the urban-owned write-provenance sidecar table (ProcessOS domain-signal plane, D0).
|
|
61
|
+
* One append per app `Table.insert` performed inside a job, linking the written row —
|
|
62
|
+
* `(source, table_name, pk_value)` — to the process instance/element that wrote it. Follows the
|
|
63
|
+
* `_urban_` convention so it is automatically excluded from domain-model introspection,
|
|
64
|
+
* DB Manager, and forms (see `gateway.ts` `schema()`), and never collides with an app table. */
|
|
65
|
+
export const WRITE_PROVENANCE_TABLE = "_urban_write_provenance";
|
|
66
|
+
|
|
67
|
+
/** Provision the write-provenance sidecar on a source (idempotent). Domain-free: it records only
|
|
68
|
+
* the join key + timestamps, never any row content. */
|
|
69
|
+
export function ensureProvenanceTable(db: SqliteDb): void {
|
|
70
|
+
db.exec(
|
|
71
|
+
`CREATE TABLE IF NOT EXISTS ${WRITE_PROVENANCE_TABLE} (` +
|
|
72
|
+
"seq INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
|
73
|
+
"source TEXT NOT NULL, " +
|
|
74
|
+
"table_name TEXT NOT NULL, " +
|
|
75
|
+
"pk_value TEXT NOT NULL, " +
|
|
76
|
+
"instance_key TEXT, " +
|
|
77
|
+
"element_id TEXT, " +
|
|
78
|
+
"job_type TEXT, " +
|
|
79
|
+
"op TEXT NOT NULL DEFAULT 'insert', " +
|
|
80
|
+
"at TEXT NOT NULL)",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Build the {@link InsertObserver} that captures write-provenance for one source. Best-effort:
|
|
85
|
+
* it records a row only when a job execution context with an instance key is active (a worker
|
|
86
|
+
* handler is running) — writes outside a job (e.g. HTTP actions) record nothing, degrading
|
|
87
|
+
* gracefully. It skips urban/engine-internal tables so the provenance table never records
|
|
88
|
+
* itself. It runs on the same connection as the insert, so inside a `tx` the provenance row is
|
|
89
|
+
* committed/rolled back atomically with the app write. `Table.insert` wraps this defensively, so
|
|
90
|
+
* a throw here can never break the app insert. */
|
|
91
|
+
export function makeProvenanceRecorder(
|
|
92
|
+
host: HostContext,
|
|
93
|
+
db: SqliteDb,
|
|
94
|
+
source: string,
|
|
95
|
+
): InsertObserver {
|
|
96
|
+
return (table, pk) => {
|
|
97
|
+
if (table.startsWith("_urban_") || table.startsWith("_nano_") || table.startsWith("sqlite_")) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const ctx = currentJobContext();
|
|
101
|
+
if (!ctx || ctx.instanceKey == null) return;
|
|
102
|
+
db.run(
|
|
103
|
+
`INSERT INTO ${WRITE_PROVENANCE_TABLE} ` +
|
|
104
|
+
"(source, table_name, pk_value, instance_key, element_id, job_type, op, at) " +
|
|
105
|
+
"VALUES (?, ?, ?, ?, ?, ?, 'insert', ?)",
|
|
106
|
+
[
|
|
107
|
+
source,
|
|
108
|
+
table,
|
|
109
|
+
String(pk),
|
|
110
|
+
ctx.instanceKey,
|
|
111
|
+
ctx.elementId ?? null,
|
|
112
|
+
ctx.jobType ?? null,
|
|
113
|
+
new Date(host.now()).toISOString(),
|
|
114
|
+
],
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
54
119
|
/** Resolve a datasource `url` to its on-disk SQLite path against `root`. The result is absolute
|
|
55
120
|
* when the resolved path is absolute (either because `url` names an absolute path or `root` is
|
|
56
121
|
* absolute); if `root` is relative (e.g. "." as used by the CLI/tests) a relative `url` stays
|
|
@@ -320,6 +385,11 @@ async function provisionSqlite(
|
|
|
320
385
|
const migrationsApplied = src.migrations
|
|
321
386
|
? await applyMigrations(ctx.host, db, ctx.root, src.migrations)
|
|
322
387
|
: [];
|
|
388
|
+
// Provision the write-provenance sidecar and wire the gateway's insert observer to it, so
|
|
389
|
+
// every app `Table.insert` performed inside a job is linked to its process instance/element
|
|
390
|
+
// (ProcessOS domain-signal capture, D0). Domain-free and non-invasive — the sidecar lives
|
|
391
|
+
// outside the domain model.
|
|
392
|
+
ensureProvenanceTable(db);
|
|
323
393
|
ctx.host.log("info", `datasource: provisioned "${name}"`, {
|
|
324
394
|
driver: "sqlite",
|
|
325
395
|
path: resolveSqlitePath(ctx.root, src.url),
|
|
@@ -329,7 +399,7 @@ async function provisionSqlite(
|
|
|
329
399
|
name,
|
|
330
400
|
driver: "sqlite",
|
|
331
401
|
db,
|
|
332
|
-
source: makeGateway(db),
|
|
402
|
+
source: makeGateway(db, makeProvenanceRecorder(ctx.host, db, name)),
|
|
333
403
|
migrationsApplied,
|
|
334
404
|
close: () => db.close(),
|
|
335
405
|
};
|