@nanobpm/urban 0.78.0 → 0.80.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/core/modules/canonical-projections.d.ts +13 -0
- package/dist/runtime/core/modules/canonical-projections.js +30 -0
- package/dist/runtime/core/modules/formjs.gen.d.ts +1 -0
- package/dist/runtime/core/modules/formjs.gen.js +5 -0
- package/dist/runtime/core/modules/forms.d.ts +22 -0
- package/dist/runtime/core/modules/forms.js +59 -0
- package/dist/runtime/core/modules/instance-state-store.d.ts +50 -0
- package/dist/runtime/core/modules/instance-state-store.js +101 -0
- package/dist/runtime/core/modules/open-user-tasks-store.d.ts +63 -0
- package/dist/runtime/core/modules/open-user-tasks-store.js +135 -0
- package/dist/runtime/core/modules/pages.js +36 -0
- package/dist/runtime/core/modules/runtime.gen.js +1 -1
- package/dist/runtime/core/modules/surfaces.js +18 -124
- package/dist/runtime/core/modules/workers.js +33 -0
- package/dist/runtime/index.d.ts +5 -0
- package/dist/runtime/index.js +8 -1
- package/package.json +1 -1
- package/src/runtime/browser/fake-dom.test-utils.ts +114 -0
- package/src/runtime/browser/formjs.browser.js +192 -0
- package/src/runtime/browser/formjs.browser.test.ts +136 -0
- package/src/runtime/browser/runtime.browser.js +101 -1
- package/src/runtime/browser/runtime.engineform.test.ts +158 -0
- package/src/runtime/core/modules/canonical-projections.test.ts +190 -0
- package/src/runtime/core/modules/canonical-projections.ts +38 -0
- package/src/runtime/core/modules/formjs.gen.ts +6 -0
- package/src/runtime/core/modules/forms.test.ts +122 -0
- package/src/runtime/core/modules/forms.ts +67 -0
- package/src/runtime/core/modules/gen-artifacts.test.ts +41 -0
- package/src/runtime/core/modules/instance-state-store.test.ts +143 -0
- package/src/runtime/core/modules/instance-state-store.ts +135 -0
- package/src/runtime/core/modules/open-user-tasks-store.test.ts +156 -0
- package/src/runtime/core/modules/open-user-tasks-store.ts +172 -0
- package/src/runtime/core/modules/pages.test.ts +99 -0
- package/src/runtime/core/modules/pages.ts +38 -0
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
- package/src/runtime/core/modules/surfaces.ts +19 -121
- package/src/runtime/core/modules/workers.ts +33 -0
- package/src/runtime/index.ts +29 -1
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
// Surfaces contribute routes to the shared server.
|
|
9
9
|
|
|
10
10
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { html, json, normalizeRoutePath, type Route } from "../router.ts";
|
|
12
|
+
import { FORMJS_JS } from "./formjs.gen.ts";
|
|
13
|
+
import { completeUserTaskResponse, resolveFormResponse } from "./forms.ts";
|
|
13
14
|
import { mountActions } from "./actions.ts";
|
|
14
15
|
import { mountAgent } from "./agent.ts";
|
|
15
16
|
import { mountApi } from "./api.ts";
|
|
@@ -42,13 +43,15 @@ function inboxPage(): string {
|
|
|
42
43
|
ul{list-style:none;padding:0}li{margin:.4rem 0;display:flex;align-items:center;gap:.5rem}
|
|
43
44
|
code{background:#f4f4f4;padding:.1rem .3rem;border-radius:3px}
|
|
44
45
|
button{font:inherit;padding:.2rem .6rem;cursor:pointer}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
.njf-field label{display:block;margin:.6rem 0 .2rem;font-weight:600}
|
|
47
|
+
.njf-field input,.njf-field textarea,.njf-field select{font:inherit;padding:.3rem;width:100%;box-sizing:border-box;max-width:24rem}
|
|
48
|
+
.njf-field input[type=checkbox],.njf-field input[type=radio]{width:auto}
|
|
49
|
+
.njf-actions{margin-top:.6rem}
|
|
48
50
|
#form{margin-top:1.5rem;border-top:1px solid #ddd;padding-top:1rem}
|
|
49
51
|
.muted{color:#666}</style>
|
|
50
52
|
<h1>Task inbox</h1><ul id="tasks"><li>loading…</li></ul>
|
|
51
53
|
<div id="form"></div>
|
|
54
|
+
<script>${FORMJS_JS}</script>
|
|
52
55
|
<script>
|
|
53
56
|
const BASE=location.pathname.replace(/[/]+$/,'');
|
|
54
57
|
const tasksEl=document.getElementById('tasks');
|
|
@@ -83,7 +86,14 @@ function openForm(t){
|
|
|
83
86
|
api('/api/form?'+q).then(f=>{
|
|
84
87
|
formEl.replaceChildren();
|
|
85
88
|
if(!f||!f.schema){renderNoForm(t);return;}
|
|
86
|
-
|
|
89
|
+
// Reuse the ONE shared form-js renderer (NanoFormJs) — no fork with the pages surface.
|
|
90
|
+
formEl.appendChild(NanoFormJs.renderForm(f.schema,{
|
|
91
|
+
heading:'Task '+(t.elementId||t.userTaskKey),
|
|
92
|
+
submitLabel:'Submit',
|
|
93
|
+
cancelLabel:'Cancel',
|
|
94
|
+
onSubmit:(variables)=>complete(t.userTaskKey,variables),
|
|
95
|
+
onCancel:loadTasks,
|
|
96
|
+
}));
|
|
87
97
|
}).catch(err=>{formEl.replaceChildren(el('p',{class:'muted'},'Failed to load form: '+err.message));});
|
|
88
98
|
}
|
|
89
99
|
|
|
@@ -95,93 +105,6 @@ function renderNoForm(t){
|
|
|
95
105
|
formEl.appendChild(bar);
|
|
96
106
|
}
|
|
97
107
|
|
|
98
|
-
function renderForm(t,schema){
|
|
99
|
-
const form=document.createElement('form');
|
|
100
|
-
form.appendChild(el('h2',{},'Task '+(t.elementId||t.userTaskKey)));
|
|
101
|
-
const components=Array.isArray(schema.components)?schema.components:[];
|
|
102
|
-
const inputs=[];
|
|
103
|
-
for(const c of components){
|
|
104
|
-
const built=buildField(c);
|
|
105
|
-
if(!built)continue;
|
|
106
|
-
form.appendChild(built.field);
|
|
107
|
-
if(built.read)inputs.push(built.read);
|
|
108
|
-
}
|
|
109
|
-
const submit=btn('Submit',null);submit.type='submit';
|
|
110
|
-
const bar=el('div',{});bar.appendChild(submit);bar.appendChild(btn('Cancel',loadTasks));
|
|
111
|
-
form.appendChild(bar);
|
|
112
|
-
form.addEventListener('submit',e=>{
|
|
113
|
-
e.preventDefault();
|
|
114
|
-
// Null-prototype: field keys come from the engine-supplied form schema. A component
|
|
115
|
-
// keyed '__proto__'/'constructor' must land as a plain own property (and round-trip
|
|
116
|
-
// through JSON), never mutate a prototype — no prototype pollution in the page runtime.
|
|
117
|
-
const variables=Object.create(null);
|
|
118
|
-
for(const read of inputs){const kv=read();if(kv)variables[kv.key]=kv.value;}
|
|
119
|
-
submit.disabled=true;
|
|
120
|
-
complete(t.userTaskKey,variables).catch(()=>{submit.disabled=false;});
|
|
121
|
-
});
|
|
122
|
-
formEl.appendChild(form);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function buildField(c){
|
|
126
|
-
const type=c&&c.type;
|
|
127
|
-
// Static (keyless) components: show text as a paragraph, ignore layout-only ones.
|
|
128
|
-
if(type==='text'){return {field:el('p',{class:'muted'},typeof c.text==='string'?c.text:'')};}
|
|
129
|
-
const key=c&&c.key;
|
|
130
|
-
if(!key)return null;
|
|
131
|
-
const wrap=el('div',{class:'field'});
|
|
132
|
-
const label=typeof c.label==='string'&&c.label?c.label:key;
|
|
133
|
-
if(type==='checkbox'){
|
|
134
|
-
const input=document.createElement('input');input.type='checkbox';
|
|
135
|
-
const lab=el('label',{});lab.appendChild(input);lab.appendChild(document.createTextNode(' '+label));
|
|
136
|
-
wrap.appendChild(lab);
|
|
137
|
-
return {field:wrap,read:()=>({key,value:input.checked})};
|
|
138
|
-
}
|
|
139
|
-
const labelEl=el('label',{},label);
|
|
140
|
-
wrap.appendChild(labelEl);
|
|
141
|
-
let input;
|
|
142
|
-
if(type==='textarea'){input=document.createElement('textarea');}
|
|
143
|
-
else if(type==='select'){
|
|
144
|
-
input=document.createElement('select');
|
|
145
|
-
input.appendChild(el('option',{value:''},'—'));
|
|
146
|
-
for(const o of (Array.isArray(c.values)?c.values:[])){
|
|
147
|
-
input.appendChild(el('option',{value:String(o.value)},String(o.label!=null?o.label:o.value)));
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
else if(type==='radio'){
|
|
151
|
-
const name='r'+Math.random().toString(36).slice(2);
|
|
152
|
-
const group=el('div',{});
|
|
153
|
-
for(const o of (Array.isArray(c.values)?c.values:[])){
|
|
154
|
-
const rlab=el('label',{});
|
|
155
|
-
const r=document.createElement('input');r.type='radio';r.name=name;r.value=String(o.value);
|
|
156
|
-
rlab.appendChild(r);rlab.appendChild(document.createTextNode(' '+String(o.label!=null?o.label:o.value)));
|
|
157
|
-
group.appendChild(rlab);
|
|
158
|
-
}
|
|
159
|
-
wrap.appendChild(group);
|
|
160
|
-
return {field:wrap,read:()=>{const sel=group.querySelector('input:checked');return sel?{key,value:sel.value}:null;}};
|
|
161
|
-
}
|
|
162
|
-
else{
|
|
163
|
-
input=document.createElement('input');
|
|
164
|
-
input.type=(type==='number')?'number':(type==='datetime'?'datetime-local':'text');
|
|
165
|
-
}
|
|
166
|
-
// Associate the label with the input (for/id) so screen readers announce it on focus.
|
|
167
|
-
const fieldId='f'+Math.random().toString(36).slice(2);
|
|
168
|
-
input.id=fieldId;labelEl.setAttribute('for',fieldId);
|
|
169
|
-
wrap.appendChild(input);
|
|
170
|
-
const isNumber=type==='number';
|
|
171
|
-
return {field:wrap,read:()=>{
|
|
172
|
-
const raw=input.value;
|
|
173
|
-
if(raw===''||raw==null)return null;
|
|
174
|
-
if(isNumber){
|
|
175
|
-
const n=Number(raw);
|
|
176
|
-
// A non-numeric value in a number field is only reachable via tampering (the browser
|
|
177
|
-
// blanks invalid type=number input). Treat it as absent rather than silently submitting
|
|
178
|
-
// NaN, which JSON.stringify serializes as null — quietly changing the submitted value.
|
|
179
|
-
return Number.isFinite(n)?{key,value:n}:null;
|
|
180
|
-
}
|
|
181
|
-
return {key,value:raw};
|
|
182
|
-
}};
|
|
183
|
-
}
|
|
184
|
-
|
|
185
108
|
function complete(userTaskKey,variables){
|
|
186
109
|
return api('/api/complete',{method:'POST',headers:{'content-type':'application/json'},
|
|
187
110
|
body:JSON.stringify({userTaskKey,variables})}).then(loadTasks);
|
|
@@ -236,39 +159,14 @@ export function mountSurfaces(ctx: RuntimeContext, app: AppApi): SurfacesHandle
|
|
|
236
159
|
method: "GET",
|
|
237
160
|
path: `${base}/api/form`,
|
|
238
161
|
source: "surface:taskInbox",
|
|
239
|
-
handler: async (req) =>
|
|
240
|
-
|
|
241
|
-
const formId = req.query.get("formId") ?? undefined;
|
|
242
|
-
// Reject only when *neither* identifier is present. Presence follows getForm's
|
|
243
|
-
// canonical rule (`presentFormIdentifier`: empty/whitespace = absent) so a
|
|
244
|
-
// whitespace-only `?formKey= ` request 400s here instead of slipping past a raw
|
|
245
|
-
// truthiness check and returning a spurious 204. The raw values are still passed
|
|
246
|
-
// through unchanged — resolving a blank key to its `formId` fallback is getForm's
|
|
247
|
-
// single responsibility.
|
|
248
|
-
if (!presentFormIdentifier(formKey) && !presentFormIdentifier(formId))
|
|
249
|
-
return json({ error: "formKey or formId required" }, 400);
|
|
250
|
-
const form = await app.engine.getForm({ formKey, formId });
|
|
251
|
-
// A task whose form can't be resolved returns 204: the client renders the
|
|
252
|
-
// no-form fallback rather than erroring.
|
|
253
|
-
if (!form) return noContent();
|
|
254
|
-
return json(form);
|
|
255
|
-
},
|
|
162
|
+
handler: async (req) =>
|
|
163
|
+
resolveFormResponse(app.engine, req.query.get("formKey") ?? undefined, req.query.get("formId") ?? undefined),
|
|
256
164
|
});
|
|
257
165
|
routes.push({
|
|
258
166
|
method: "POST",
|
|
259
167
|
path: `${base}/api/complete`,
|
|
260
168
|
source: "surface:taskInbox",
|
|
261
|
-
handler: async (req) =>
|
|
262
|
-
let body: { userTaskKey?: string; variables?: Record<string, unknown> };
|
|
263
|
-
try {
|
|
264
|
-
body = JSON.parse((await req.text()) || "{}");
|
|
265
|
-
} catch {
|
|
266
|
-
return json({ error: "invalid JSON body" }, 400);
|
|
267
|
-
}
|
|
268
|
-
if (!body.userTaskKey) return json({ error: "userTaskKey required" }, 400);
|
|
269
|
-
await app.engine.completeUserTask(body.userTaskKey, body.variables);
|
|
270
|
-
return json({ ok: true });
|
|
271
|
-
},
|
|
169
|
+
handler: async (req) => completeUserTaskResponse(app.engine, await req.text()),
|
|
272
170
|
});
|
|
273
171
|
}
|
|
274
172
|
|
|
@@ -12,6 +12,9 @@ import type { JobExecContext } from "../execContext.ts";
|
|
|
12
12
|
import { readLineage } from "../lineage.ts";
|
|
13
13
|
import { LineageStore } from "./lineage-store.ts";
|
|
14
14
|
import { readModelRegistry } from "../read-model.ts";
|
|
15
|
+
import { registerCanonicalProjections } from "./canonical-projections.ts";
|
|
16
|
+
import { InstanceStateStore } from "./instance-state-store.ts";
|
|
17
|
+
import { OpenUserTasksStore } from "./open-user-tasks-store.ts";
|
|
15
18
|
import { defaultScheduler, schedulerClock, type SchedulerDeps } from "./scheduler.ts";
|
|
16
19
|
|
|
17
20
|
/** The subset of the engine SDK the LLM decision-rails need: evaluate a DMN decision,
|
|
@@ -158,6 +161,28 @@ function tryProvisionReadModelViews(app: AppApi, log: RuntimeContext["host"]["lo
|
|
|
158
161
|
}
|
|
159
162
|
}
|
|
160
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Provision the canonical engine-truth projection sidecars (ADR 0065, proposal point #1) on the app's
|
|
166
|
+
* DEFAULT data source — `_urban_open_user_tasks` and `_urban_instance_state`, the sources a read model
|
|
167
|
+
* `EXISTS`-derives its status edges from. Exactly the per-source boot path `tryLineageStore` uses: an
|
|
168
|
+
* app with no datasource provisions nothing, and a schema failure degrades to no projection tables
|
|
169
|
+
* rather than a failed worker mount. The DSL projection-name registration is done unconditionally by
|
|
170
|
+
* the caller (so read models compile even without a datasource); this only creates the physical tables.
|
|
171
|
+
*/
|
|
172
|
+
function tryProvisionCanonicalProjections(app: AppApi, log: RuntimeContext["host"]["log"]): void {
|
|
173
|
+
if (!app.data.hasDefaultSource()) {
|
|
174
|
+
log("debug", "canonical-projections: no default data source; projection tables not provisioned");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const { db } = app.data.source();
|
|
179
|
+
new OpenUserTasksStore(db).ensureSchema();
|
|
180
|
+
new InstanceStateStore(db).ensureSchema();
|
|
181
|
+
} catch (err) {
|
|
182
|
+
log("warn", "canonical-projections: failed to provision projection tables", { error: String(err) });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
161
186
|
/** Record an activated job's lineage edge, never letting a projection write break the job. */
|
|
162
187
|
function recordJobLineage(store: LineageStore, job: EngineJob, log: RuntimeContext["host"]["log"]): void {
|
|
163
188
|
try {
|
|
@@ -218,6 +243,14 @@ export async function mountWorkers(
|
|
|
218
243
|
// The framework read-model VIEWs (ADR 0065): apply every registered managed VIEW to the app's
|
|
219
244
|
// default data source, the same per-source boot path lineage uses. A no-op until a sibling task
|
|
220
245
|
// registers a read model into `readModelRegistry`.
|
|
246
|
+
//
|
|
247
|
+
// Register the canonical engine-truth projections (`urban_open_user_tasks`, `urban_instance_state`)
|
|
248
|
+
// FIRST and unconditionally: a read model's managed VIEW resolves each projection name to its
|
|
249
|
+
// physical `_urban_` table at DDL-compile time, so the name→table mapping must exist before
|
|
250
|
+
// `tryProvisionReadModelViews` compiles any VIEW that `EXISTS`-references a projection. Then
|
|
251
|
+
// provision the projection tables per source (absent-safe), and finally the read-model VIEWs.
|
|
252
|
+
registerCanonicalProjections();
|
|
253
|
+
tryProvisionCanonicalProjections(app, ctx.host.log);
|
|
221
254
|
tryProvisionReadModelViews(app, ctx.host.log);
|
|
222
255
|
|
|
223
256
|
// Cache module loads so a multi-type handler module is imported once.
|
package/src/runtime/index.ts
CHANGED
|
@@ -279,7 +279,35 @@ export type {
|
|
|
279
279
|
WhenClause,
|
|
280
280
|
} from "./core/read-model.ts";
|
|
281
281
|
|
|
282
|
-
//
|
|
282
|
+
// Canonical engine-truth projection sidecars (ADR 0065, proposal point #1): two framework-owned,
|
|
283
|
+
// per-source projections the DSL `exists(...)` derives over — `urban_open_user_tasks` (which instances
|
|
284
|
+
// are parked on a human) and `urban_instance_state` (per-instance engine lifecycle state). Provisioned
|
|
285
|
+
// next to the lineage sidecar in core/modules/workers.ts; registered into `projectionRegistry` under
|
|
286
|
+
// their stable DSL names via `registerCanonicalProjections()`.
|
|
287
|
+
export {
|
|
288
|
+
CANONICAL_PROJECTIONS,
|
|
289
|
+
registerCanonicalProjections,
|
|
290
|
+
} from "./core/modules/canonical-projections.ts";
|
|
291
|
+
export {
|
|
292
|
+
OPEN_USER_TASKS_PROJECTION,
|
|
293
|
+
OPEN_USER_TASKS_SCHEMA_SQL,
|
|
294
|
+
OPEN_USER_TASKS_TABLE,
|
|
295
|
+
OpenUserTasksStore,
|
|
296
|
+
} from "./core/modules/open-user-tasks-store.ts";
|
|
297
|
+
export type {
|
|
298
|
+
OpenUserTaskRow,
|
|
299
|
+
OpenUserTasksStoreOptions,
|
|
300
|
+
} from "./core/modules/open-user-tasks-store.ts";
|
|
301
|
+
export {
|
|
302
|
+
INSTANCE_STATE_PROJECTION,
|
|
303
|
+
INSTANCE_STATE_SCHEMA_SQL,
|
|
304
|
+
INSTANCE_STATE_TABLE,
|
|
305
|
+
InstanceStateStore,
|
|
306
|
+
} from "./core/modules/instance-state-store.ts";
|
|
307
|
+
export type {
|
|
308
|
+
InstanceStateRow,
|
|
309
|
+
InstanceStateStoreOptions,
|
|
310
|
+
} from "./core/modules/instance-state-store.ts";
|
|
283
311
|
// instance in?" from the BPMN scope hierarchy + write-provenance (Tier 0, zero declaration), an
|
|
284
312
|
// optional `nano:phase` override (Tier 1), and a lineage rollup for the epic frontier (Tier 2).
|
|
285
313
|
export {
|