@nanobpm/urban 0.47.0 → 0.49.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/README.md +80 -21
- package/dist/runtime/adapters/deno.js +22 -4
- package/dist/runtime/adapters/node.js +34 -4
- package/dist/runtime/core/context.d.ts +0 -4
- package/dist/runtime/core/glob.d.ts +9 -0
- package/dist/runtime/core/glob.js +21 -0
- package/dist/runtime/core/host.d.ts +71 -8
- package/dist/runtime/core/host.js +13 -0
- package/dist/runtime/core/manifest.d.ts +89 -2
- package/dist/runtime/core/manifest.js +72 -0
- package/dist/runtime/core/modules/deploy.d.ts +9 -3
- package/dist/runtime/core/modules/deploy.js +69 -37
- package/dist/runtime/core/modules/instance-tracking.d.ts +1 -1
- package/dist/runtime/core/modules/instance-tracking.js +20 -5
- package/dist/runtime/core/modules/pages.js +77 -5
- package/dist/runtime/core/modules/surfaces.js +192 -18
- package/dist/runtime/core/router.d.ts +7 -0
- package/dist/runtime/core/router.js +9 -0
- package/dist/runtime/core/runtime.d.ts +0 -5
- package/dist/runtime/core/runtime.js +27 -4
- package/dist/runtime/core/validate.js +65 -1
- package/dist/runtime/engine/nanosdk.d.ts +9 -6
- package/dist/runtime/engine/nanosdk.js +65 -1
- package/dist/runtime/index.d.ts +3 -5
- package/dist/runtime/index.js +3 -4
- package/dist/runtime/run.js +0 -1
- package/dist/toolkit/derivers/shapes.d.ts +5 -0
- package/dist/toolkit/derivers/shapes.js +90 -0
- package/dist/toolkit/derivers/worker-io.d.ts +25 -0
- package/dist/toolkit/derivers/worker-io.js +104 -11
- package/dist/toolkit/fsio.js +9 -0
- package/dist/toolkit/gen.d.ts +26 -1
- package/dist/toolkit/gen.js +103 -22
- package/dist/toolkit/models.d.ts +6 -4
- package/dist/toolkit/models.js +7 -4
- package/package.json +1 -1
- package/src/runtime/adapters/deno.ts +26 -6
- package/src/runtime/adapters/node.test.ts +67 -0
- package/src/runtime/adapters/node.ts +37 -4
- package/src/runtime/core/context.ts +0 -4
- package/src/runtime/core/glob.ts +24 -0
- package/src/runtime/core/host.ts +74 -4
- package/src/runtime/core/manifest.test.ts +61 -1
- package/src/runtime/core/manifest.ts +136 -2
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +1 -0
- package/src/runtime/core/modules/cancel.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/deploy.test.ts +97 -161
- package/src/runtime/core/modules/deploy.ts +69 -37
- package/src/runtime/core/modules/instance-tracking.test.ts +90 -0
- package/src/runtime/core/modules/instance-tracking.ts +19 -6
- package/src/runtime/core/modules/pages.test.ts +71 -3
- package/src/runtime/core/modules/pages.ts +77 -5
- package/src/runtime/core/modules/surfaces.test.ts +198 -5
- package/src/runtime/core/modules/surfaces.ts +191 -18
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/router.test.ts +10 -1
- package/src/runtime/core/router.ts +10 -0
- package/src/runtime/core/runtime.test.ts +67 -1
- package/src/runtime/core/runtime.ts +28 -9
- package/src/runtime/core/validate.test.ts +145 -0
- package/src/runtime/core/validate.ts +64 -1
- package/src/runtime/engine/nanosdk.test.ts +122 -0
- package/src/runtime/engine/nanosdk.ts +72 -2
- package/src/runtime/index.ts +11 -4
- package/src/runtime/run.ts +0 -1
- package/src/toolkit/derivers/shapes.test.ts +66 -0
- package/src/toolkit/derivers/shapes.ts +93 -0
- package/src/toolkit/derivers/worker-io.test.ts +131 -0
- package/src/toolkit/derivers/worker-io.ts +116 -9
- package/src/toolkit/fsio.ts +8 -0
- package/src/toolkit/gen.test.ts +81 -10
- package/src/toolkit/gen.ts +109 -22
- package/src/toolkit/models.test.ts +5 -5
- package/src/toolkit/models.ts +8 -5
- package/dist/runtime/core/modules/templates.d.ts +0 -18
- package/dist/runtime/core/modules/templates.js +0 -133
- package/src/runtime/core/modules/templates.ts +0 -153
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
// deploy — resolve the
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// deploy — resolve the app's deployable resources and send them to the engine.
|
|
2
|
+
//
|
|
3
|
+
// Resources are found by ONE of two paths:
|
|
4
|
+
// 1. Convention (the default, ADR 0062): when the manifest declares no `models`, deployables are
|
|
5
|
+
// discovered by walking `resources/` — shallow, one level deep. `resources/` is deploy-only:
|
|
6
|
+
// everything under it (and nothing outside it) is deployed. Docs (`docs/`, `AGENTS.md`,
|
|
7
|
+
// top-level `*.md`) live outside `resources/` and are never swept in.
|
|
8
|
+
// 2. Explicit override: when the manifest declares `models` globs, those are used exactly (the
|
|
9
|
+
// convention walk is skipped) — the escape hatch for a non-convention layout.
|
|
10
|
+
//
|
|
11
|
+
// Either way the deploy dedupe key is the resource's basename (filename only), so a basename
|
|
12
|
+
// collision — two files with the same name in different directories — would silently clobber one
|
|
13
|
+
// resource with another at the engine. We detect that up front and fail loudly instead.
|
|
14
|
+
import { discoverResources, expandPatterns } from "../glob.js";
|
|
15
|
+
/** The convention directory: deploy-only, walked one level deep when no `models` are declared. */
|
|
16
|
+
export const RESOURCES_DIR = "resources";
|
|
5
17
|
function contentTypeFor(path) {
|
|
6
18
|
if (path.endsWith(".bpmn"))
|
|
7
19
|
return "text/xml";
|
|
@@ -15,45 +27,65 @@ function baseName(path) {
|
|
|
15
27
|
const i = path.lastIndexOf("/");
|
|
16
28
|
return i >= 0 ? path.slice(i + 1) : path;
|
|
17
29
|
}
|
|
18
|
-
/**
|
|
19
|
-
*
|
|
20
|
-
|
|
30
|
+
/** Fail loudly when two resolved files share a basename (the deploy dedupe key), naming the
|
|
31
|
+
* colliding paths so the author can rename or relocate one. */
|
|
32
|
+
function assertNoBasenameCollisions(files) {
|
|
33
|
+
const byName = new Map();
|
|
34
|
+
for (const path of files) {
|
|
35
|
+
const name = baseName(path);
|
|
36
|
+
const list = byName.get(name);
|
|
37
|
+
if (list)
|
|
38
|
+
list.push(path);
|
|
39
|
+
else
|
|
40
|
+
byName.set(name, [path]);
|
|
41
|
+
}
|
|
42
|
+
const collisions = [...byName.entries()].filter(([, paths]) => paths.length > 1);
|
|
43
|
+
if (collisions.length === 0)
|
|
44
|
+
return;
|
|
45
|
+
const detail = collisions
|
|
46
|
+
.map(([name, paths]) => ` ${name}: ${paths.slice().sort().join(", ")}`)
|
|
47
|
+
.join("\n");
|
|
48
|
+
throw new Error("deploy: basename collision — resources are deployed keyed by filename, so two files with " +
|
|
49
|
+
`the same name would clobber each other. Rename or relocate one of:\n${detail}`);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Deploy the app's processes, decisions and forms.
|
|
53
|
+
*
|
|
54
|
+
* With no manifest `models`, deployables are discovered by convention under `resources/` (shallow,
|
|
55
|
+
* one level deep). With `models` globs declared, those are used verbatim. Basename collisions are a
|
|
56
|
+
* hard error in either mode.
|
|
57
|
+
*/
|
|
21
58
|
export async function deployModels(ctx) {
|
|
22
|
-
const models = ctx.manifest.models
|
|
59
|
+
const models = ctx.manifest.models;
|
|
23
60
|
const patterns = [
|
|
24
|
-
...(models
|
|
25
|
-
...(models
|
|
26
|
-
...(models
|
|
61
|
+
...(models?.processes ?? []),
|
|
62
|
+
...(models?.decisions ?? []),
|
|
63
|
+
...(models?.forms ?? []),
|
|
27
64
|
];
|
|
28
|
-
|
|
65
|
+
// Convention is keyed off the *absence* of the `models` block, not an empty pattern set: a
|
|
66
|
+
// declared `models` (even one resolving to zero files) is an explicit override that must NOT
|
|
67
|
+
// silently fall back to the `resources/` walk (ADR 0062: "no `models` block → convention;
|
|
68
|
+
// `models` present → exact override").
|
|
69
|
+
const byConvention = models === undefined;
|
|
70
|
+
const files = byConvention
|
|
71
|
+
? await discoverResources(ctx.host, ctx.root, RESOURCES_DIR)
|
|
72
|
+
: await expandPatterns(ctx.host, ctx.root, patterns);
|
|
29
73
|
if (files.length === 0) {
|
|
30
|
-
|
|
74
|
+
if (byConvention) {
|
|
75
|
+
ctx.host.log("info", "deploy: no resources found by convention", { dir: RESOURCES_DIR });
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
ctx.host.log("info", "deploy: no model files matched", { patterns });
|
|
79
|
+
}
|
|
31
80
|
return { deployed: 0, files: [] };
|
|
32
81
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
let content = await ctx.host.readTextFile(path);
|
|
40
|
-
// Only substitute into the model content types we know how to escape (XML for .bpmn/.dmn,
|
|
41
|
-
// JSON for .form). Unknown extensions map to application/octet-stream; running the default
|
|
42
|
-
// XML escaper over them could deterministically corrupt a non-XML/JSON resource that a
|
|
43
|
-
// manifest pattern matched by accident, so leave those files untouched.
|
|
44
|
-
if (hasTemplates && contentType !== "application/octet-stream") {
|
|
45
|
-
const applied = applyTemplates(content, contentType, templates);
|
|
46
|
-
content = applied.content;
|
|
47
|
-
if (applied.unresolved.length > 0) {
|
|
48
|
-
ctx.host.log("warn", "deploy: unresolved template placeholders", {
|
|
49
|
-
file: path,
|
|
50
|
-
unresolved: applied.unresolved,
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
return { name: baseName(path), content, contentType };
|
|
55
|
-
}));
|
|
82
|
+
assertNoBasenameCollisions(files);
|
|
83
|
+
const resources = await Promise.all(files.map(async (path) => ({
|
|
84
|
+
name: baseName(path),
|
|
85
|
+
content: await ctx.host.readTextFile(path),
|
|
86
|
+
contentType: contentTypeFor(path),
|
|
87
|
+
})));
|
|
56
88
|
const { deployed } = await ctx.engine.deployResources(resources);
|
|
57
|
-
ctx.host.log("info", "deploy:
|
|
89
|
+
ctx.host.log("info", "deploy: resources deployed", { deployed, files, byConvention });
|
|
58
90
|
return { deployed, files };
|
|
59
91
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
|
|
2
|
-
import type
|
|
2
|
+
import { type InstanceTracking } from "../manifest.ts";
|
|
3
3
|
import { type SchedulerDeps } from "./scheduler.ts";
|
|
4
4
|
/** Default poll interval when a binding does not set `pollMs`. */
|
|
5
5
|
export declare const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15000;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// — and never on `COMPLETED`, whose terminal row-write the app's own finalize worker owns
|
|
13
13
|
// (reconciling it here would race that worker and could clobber a legitimately-completed
|
|
14
14
|
// row). The poll is driven through the shared `SchedulerDeps` seam so tests are deterministic.
|
|
15
|
+
import { isConfiguredStatusSelector } from "../manifest.js";
|
|
15
16
|
import { defaultScheduler, MAX_TIMER_DELAY_MS } from "./scheduler.js";
|
|
16
17
|
/** Default poll interval when a binding does not set `pollMs`. */
|
|
17
18
|
export const DEFAULT_INSTANCE_TRACKING_POLL_MS = 15_000;
|
|
@@ -44,13 +45,27 @@ export async function reconcileTerminatedKey(api, bindings, processInstanceKey)
|
|
|
44
45
|
* scanned and how many rows were reconciled this tick. */
|
|
45
46
|
async function reconcileOnce(api, binding) {
|
|
46
47
|
const table = api.data.table(binding.table, binding.keyField);
|
|
47
|
-
// Select the rows worth polling.
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
48
|
+
// Select the rows worth polling. Three modes, in precedence order:
|
|
49
|
+
// - `terminalStatuses` (fail-open exclusion): poll every row whose `statusField` is NOT
|
|
50
|
+
// a terminal value. A newly-added non-terminal status is polled by default, so it can't
|
|
51
|
+
// silently fall out of reconciliation; only already-terminal rows are excluded (bounded).
|
|
52
|
+
// - `activeStatuses` (fail-closed allow-list): poll only rows in an enumerated active status.
|
|
53
|
+
// - neither: poll every row; a terminal instance's row is idempotently re-patched (the engine
|
|
54
|
+
// query returns only TERMINATED instances and the patch is a no-op once applied).
|
|
55
|
+
// `terminalStatuses` and `activeStatuses` are mutually exclusive per binding (validation rejects
|
|
56
|
+
// both); if both are somehow present, `terminalStatuses` wins (the fail-open selector). Each
|
|
57
|
+
// selector is gated through the shared `isConfiguredStatusSelector` predicate (a non-empty array
|
|
58
|
+
// of non-empty strings) — the same one validation uses — so a malformed value (a bare string, an
|
|
59
|
+
// empty array) degrades to the fail-open poll-all path here instead of `new Set("abandoned")`
|
|
60
|
+
// silently filtering by character, or a non-array crashing `activeStatuses.map(...)`.
|
|
51
61
|
let candidates;
|
|
52
62
|
const statusField = binding.statusField;
|
|
53
|
-
if (statusField && binding.
|
|
63
|
+
if (statusField && isConfiguredStatusSelector(binding.terminalStatuses)) {
|
|
64
|
+
const terminal = new Set(binding.terminalStatuses);
|
|
65
|
+
const all = await table.all();
|
|
66
|
+
candidates = all.filter((row) => !terminal.has(String(row[statusField])));
|
|
67
|
+
}
|
|
68
|
+
else if (statusField && isConfiguredStatusSelector(binding.activeStatuses)) {
|
|
54
69
|
const perStatus = await Promise.all(binding.activeStatuses.map((s) => table.find({ [statusField]: s })));
|
|
55
70
|
candidates = perStatus.flat();
|
|
56
71
|
}
|
|
@@ -513,6 +513,9 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
513
513
|
.pc-badge-warn { background:var(--nano-warn); color:#3a2a00; }
|
|
514
514
|
.pc-badge-ok { background:var(--nano-ok); }
|
|
515
515
|
.pc-badge-info { background:var(--nano-info); }
|
|
516
|
+
/* Live row-count pill (dataGrid showCount): a subtle, neutral count distinct from
|
|
517
|
+
the coloured status badges, legible in the collapse header while collapsed. */
|
|
518
|
+
.pc-count-badge { color:var(--nano-text); background:var(--nano-inset); border:1px solid var(--nano-edge); font-weight:600; }
|
|
516
519
|
:root[data-appearance="light"] .pc-badge-warn { color:#fff; }
|
|
517
520
|
@media (prefers-color-scheme: light) { :root:not([data-appearance]) .pc-badge-warn { color:#fff; } }
|
|
518
521
|
.pc-child { margin:.6rem 0; }
|
|
@@ -786,6 +789,27 @@ function el(tag, attrs = {}, ...kids) {
|
|
|
786
789
|
function interpParam(s) {
|
|
787
790
|
return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
|
|
788
791
|
}
|
|
792
|
+
|
|
793
|
+
// Interpolate {{field}} tokens in a column's per-cell template with values from
|
|
794
|
+
// the row, coercing each to a string (null/undefined → ""). Like interpParam the
|
|
795
|
+
// result is only ever set as DOM text (textContent via el()), so a substituted
|
|
796
|
+
// row value cannot inject markup or attributes. A function replacement inserts
|
|
797
|
+
// each value literally, so a "$"-bearing value can't be reinterpreted as a
|
|
798
|
+
// replacement pattern. Unknown tokens (no such field) render empty rather than
|
|
799
|
+
// throwing, matching interpParam. This is a dumb text formatter: no expressions,
|
|
800
|
+
// arithmetic, or conditionals — just field splicing. Only the row's own
|
|
801
|
+
// fields resolve (own-property gate, enumerable or not): an inherited
|
|
802
|
+
// Object.prototype key (toString, constructor, …) counts as unknown and
|
|
803
|
+
// renders "", so a token can't pick up prototype cruft instead of blank.
|
|
804
|
+
function interpTemplate(tpl, row) {
|
|
805
|
+
return typeof tpl === "string"
|
|
806
|
+
? tpl.replace(/\{\{([^{}]+)\}\}/g, (_m, name) => {
|
|
807
|
+
const key = name.trim();
|
|
808
|
+
const v = Object.prototype.hasOwnProperty.call(row, key) ? row[key] : null;
|
|
809
|
+
return v == null ? "" : String(v);
|
|
810
|
+
})
|
|
811
|
+
: tpl;
|
|
812
|
+
}
|
|
789
813
|
function renderText(node) {
|
|
790
814
|
const v = node.props.variant;
|
|
791
815
|
const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
|
|
@@ -950,8 +974,27 @@ function renderButton(node) {
|
|
|
950
974
|
// host's own explorer view in place via the nano-navigate postMessage bridge
|
|
951
975
|
// (a modified/middle click keeps the new-tab behavior). Shared by the top-level
|
|
952
976
|
// grid and child grids.
|
|
977
|
+
//
|
|
978
|
+
// Cell TEXT (the 'text' local below): the visible string a cell renders. By
|
|
979
|
+
// default it is the column's single 'field' value, but an optional
|
|
980
|
+
// 'col.template' string wins: its {{field}} tokens are spliced from the row
|
|
981
|
+
// (interpTemplate) into DOM text. A template lets one cell combine several
|
|
982
|
+
// fields / add surrounding text (e.g. "{{current_wave}}/{{wave_count}}")
|
|
983
|
+
// without denormalising a display column into the datasource. The template
|
|
984
|
+
// drives the rendered text for the plain, linkField and link
|
|
985
|
+
// (page/processExplorer) paths — a link's href still comes from its own
|
|
986
|
+
// keyField/linkField, never the template. The 'badge' presence gate and its
|
|
987
|
+
// tooltip stay keyed to the raw 'field' value (a badge shows a fixed glyph, not
|
|
988
|
+
// the field text), so a template does not change badge behaviour. Sorting,
|
|
989
|
+
// groupBy and orderBy also key off real fields, never the rendered template.
|
|
953
990
|
function gridCell(col, row) {
|
|
954
|
-
|
|
991
|
+
// Raw single-field value: drives the badge presence gate + tooltip and any
|
|
992
|
+
// field-derived defaults. Kept separate from the (possibly templated) display
|
|
993
|
+
// text so a template never re-lights or blanks a badge.
|
|
994
|
+
const rawText = row[col.field] == null ? "" : String(row[col.field]);
|
|
995
|
+
// Visible text: a col.template (interpolated from the row) wins over the
|
|
996
|
+
// raw field value, else the field value is shown verbatim.
|
|
997
|
+
const text = typeof col.template === "string" ? interpTemplate(col.template, row) : rawText;
|
|
955
998
|
// 1. badge — a compact status indicator. When the row's field value is
|
|
956
999
|
// non-empty (truthy after trimming) render a small circular badge (e.g. a
|
|
957
1000
|
// red "1" dot flagging an incident) whose tooltip is the full field text;
|
|
@@ -963,14 +1006,14 @@ function gridCell(col, row) {
|
|
|
963
1006
|
// field text is also mirrored into aria-label so assistive tech announces
|
|
964
1007
|
// the incident/status text (the visible "1" glyph alone is not meaningful).
|
|
965
1008
|
if (col.badge) {
|
|
966
|
-
if (
|
|
1009
|
+
if (rawText.trim() === "") return el("td", {});
|
|
967
1010
|
const t = col.badge.tone;
|
|
968
1011
|
const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
|
|
969
1012
|
const label = col.badge.label == null ? "1" : String(col.badge.label);
|
|
970
1013
|
return el(
|
|
971
1014
|
"td",
|
|
972
1015
|
{},
|
|
973
|
-
el("span", { class: "pc-badge pc-badge-" + tone, title:
|
|
1016
|
+
el("span", { class: "pc-badge pc-badge-" + tone, title: rawText, "aria-label": rawText }, label),
|
|
974
1017
|
);
|
|
975
1018
|
}
|
|
976
1019
|
if (col.linkField) {
|
|
@@ -1174,10 +1217,25 @@ function renderActionForm(node) {
|
|
|
1174
1217
|
return card;
|
|
1175
1218
|
}
|
|
1176
1219
|
|
|
1220
|
+
// Accessible name for the row-count badge. The visible text is just a number, so an
|
|
1221
|
+
// aria-label would override it with a bare "row count" (the count — the important part —
|
|
1222
|
+
// is dropped). Instead name it "N row(s)" and keep it in sync with the text on refresh.
|
|
1223
|
+
// Single source of truth for both the initial render and every refresh below.
|
|
1224
|
+
function rowCountLabel(n) { return n + (n === 1 ? " row" : " rows"); }
|
|
1225
|
+
|
|
1177
1226
|
function renderDataGrid(node) {
|
|
1178
1227
|
const p = node.props;
|
|
1179
1228
|
const card = el("section", { class: "pc-card" });
|
|
1180
|
-
|
|
1229
|
+
// Opt-in live row-count badge ("showCount"). Rendered inside the <h2> so a plain
|
|
1230
|
+
// (non-collapsible) grid surfaces it beside the title; makeCollapsible re-homes it
|
|
1231
|
+
// into the collapse header so it stays visible — and refresh-updated — while the
|
|
1232
|
+
// section is collapsed. refresh() below rewrites its text on every poll/pc:refresh.
|
|
1233
|
+
const countBadge = p.showCount ? el("span", { class: "pc-badge pc-count-badge", "aria-label": rowCountLabel(0) }, "0") : null;
|
|
1234
|
+
if (p.title || countBadge) {
|
|
1235
|
+
const h2 = el("h2", {}, p.title || "");
|
|
1236
|
+
if (countBadge) { if (p.title) h2.append(" "); h2.append(countBadge); }
|
|
1237
|
+
card.append(h2);
|
|
1238
|
+
}
|
|
1181
1239
|
const cols = p.columns || [];
|
|
1182
1240
|
const tabs = p.tabs || [];
|
|
1183
1241
|
const rowActions = p.rowActions || [];
|
|
@@ -1495,6 +1553,10 @@ function renderDataGrid(node) {
|
|
|
1495
1553
|
const { rows } = paramScoped && PARAM === ""
|
|
1496
1554
|
? { rows: [] }
|
|
1497
1555
|
: await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
|
|
1556
|
+
// Live row-count badge: reflect the currently-fetched, filtered (active-tab)
|
|
1557
|
+
// set — exactly what the body renders — on every refresh (poll + pc:refresh),
|
|
1558
|
+
// whether the section is expanded or collapsed.
|
|
1559
|
+
if (countBadge) { countBadge.textContent = String(rows.length); countBadge.setAttribute("aria-label", rowCountLabel(rows.length)); }
|
|
1498
1560
|
// Forget expansion / cached detail nodes for rows no longer present so the maps
|
|
1499
1561
|
// don't grow without bound and a stale answer can't resurface on a key reuse.
|
|
1500
1562
|
if (p.rowKey) {
|
|
@@ -1663,7 +1725,16 @@ function makeCollapsible(node, card) {
|
|
|
1663
1725
|
const isCard = card.tagName === "SECTION";
|
|
1664
1726
|
const container = isCard ? card : el("section", { class: "pc-card" });
|
|
1665
1727
|
const h2 = isCard && card.querySelector ? card.querySelector("h2") : null;
|
|
1666
|
-
|
|
1728
|
+
// A dataGrid with "showCount" renders a live count badge inside its <h2>. Lift it
|
|
1729
|
+
// out before deriving the title (so the count doesn't leak into the label text),
|
|
1730
|
+
// then re-home it in the header alongside the title so it stays visible — and
|
|
1731
|
+
// refresh-updated by the grid's own refresh() — while the section is collapsed.
|
|
1732
|
+
const countBadge = h2 && h2.querySelector ? h2.querySelector(".pc-count-badge") : null;
|
|
1733
|
+
if (countBadge) countBadge.remove();
|
|
1734
|
+
// Trim the derived label: after the badge is lifted out, the leftover " " whitespace
|
|
1735
|
+
// node from h2.append(" ", countBadge) would otherwise make a title-less section's
|
|
1736
|
+
// header a lone space (and leak whitespace into the storage key). Fall back to "Section".
|
|
1737
|
+
const titleText = props.title || (h2 ? h2.textContent.trim() : "") || "Section";
|
|
1667
1738
|
if (h2) h2.remove();
|
|
1668
1739
|
const body = el("div", { class: "pc-card-body" });
|
|
1669
1740
|
if (isCard) {
|
|
@@ -1678,6 +1749,7 @@ function makeCollapsible(node, card) {
|
|
|
1678
1749
|
chevron,
|
|
1679
1750
|
el("span", { class: "pc-collapse-title" }, titleText),
|
|
1680
1751
|
);
|
|
1752
|
+
if (countBadge) header.append(countBadge);
|
|
1681
1753
|
const storageKey = "pc:collapsed:" + CURRENT + ":" + (node.id || titleText);
|
|
1682
1754
|
let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
|
|
1683
1755
|
function apply() {
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// surfaces — the hosted human surfaces (ADR 0026). Implements the `taskInbox` surface: a
|
|
2
|
-
// small JSON+HTML task list backed by the engine's user-task search
|
|
3
|
-
//
|
|
4
|
-
// (
|
|
5
|
-
|
|
2
|
+
// small JSON+HTML task list backed by the engine's user-task search. Each open task links to
|
|
3
|
+
// its deployed `.form` via the engine-resolved `formKey`; the list fetches that form's form-js
|
|
4
|
+
// schema (`GET base/api/form`), renders its fields client-side with a minimal built-in
|
|
5
|
+
// renderer, and posts the entered field values as the completion `variables` (`POST
|
|
6
|
+
// base/api/complete`). A task with no linked form keeps the bare key-list + raw-complete
|
|
7
|
+
// behavior. The `chat` surface is stubbed as a mount point (LLM wiring is a separate concern).
|
|
8
|
+
// Surfaces contribute routes to the shared server.
|
|
9
|
+
import { presentFormIdentifier } from "../host.js";
|
|
10
|
+
import { html, json, noContent, normalizeRoutePath } from "../router.js";
|
|
6
11
|
import { mountActions } from "./actions.js";
|
|
7
12
|
import { mountApi } from "./api.js";
|
|
8
13
|
import { mountPages } from "./pages.js";
|
|
@@ -10,23 +15,169 @@ import { mountPages } from "./pages.js";
|
|
|
10
15
|
function escapeHtml(s) {
|
|
11
16
|
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
12
17
|
}
|
|
13
|
-
function inboxPage(
|
|
18
|
+
function inboxPage() {
|
|
19
|
+
// Reverse-proxy safe: the client derives its API base from location.pathname
|
|
20
|
+
// (where THIS page was actually served) rather than an embedded absolute
|
|
21
|
+
// route base. A hardcoded "/tasks" would escape the Nano console's
|
|
22
|
+
// path-prefixed proxy (/console/app-view/<name>/tasks) — the API fetches must
|
|
23
|
+
// inherit that prefix. Deriving from location also removes the manifest→
|
|
24
|
+
// <script> injection surface entirely: nothing manifest-supplied is embedded.
|
|
25
|
+
// All task/form values are rendered via textContent / setAttribute — never
|
|
26
|
+
// innerHTML — so nothing the engine returns can inject markup.
|
|
14
27
|
return `<!doctype html><meta charset="utf-8"><title>Task inbox</title>
|
|
15
28
|
<style>body{font:14px system-ui,sans-serif;margin:2rem;max-width:52rem}
|
|
16
|
-
li{margin:.4rem 0
|
|
29
|
+
ul{list-style:none;padding:0}li{margin:.4rem 0;display:flex;align-items:center;gap:.5rem}
|
|
30
|
+
code{background:#f4f4f4;padding:.1rem .3rem;border-radius:3px}
|
|
31
|
+
button{font:inherit;padding:.2rem .6rem;cursor:pointer}
|
|
32
|
+
form label{display:block;margin:.6rem 0 .2rem;font-weight:600}
|
|
33
|
+
form .field input,form .field textarea,form .field select{font:inherit;padding:.3rem;width:100%;box-sizing:border-box;max-width:24rem}
|
|
34
|
+
form .field input[type=checkbox],form .field input[type=radio]{width:auto}
|
|
35
|
+
#form{margin-top:1.5rem;border-top:1px solid #ddd;padding-top:1rem}
|
|
36
|
+
.muted{color:#666}</style>
|
|
17
37
|
<h1>Task inbox</h1><ul id="tasks"><li>loading…</li></ul>
|
|
38
|
+
<div id="form"></div>
|
|
18
39
|
<script>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
const BASE=location.pathname.replace(/\/+$/,'');
|
|
41
|
+
const tasksEl=document.getElementById('tasks');
|
|
42
|
+
const formEl=document.getElementById('form');
|
|
43
|
+
|
|
44
|
+
function api(path,opts){return fetch(BASE+path,opts).then(r=>{if(!r.ok)return r.text().then(t=>{throw new Error(t||r.status)});if(r.status===204)return null;return r.json();});}
|
|
45
|
+
|
|
46
|
+
function loadTasks(){
|
|
47
|
+
formEl.replaceChildren();
|
|
48
|
+
tasksEl.replaceChildren(el('li',{},'loading…'));
|
|
49
|
+
return api('/api/tasks').then(ts=>{
|
|
50
|
+
tasksEl.replaceChildren();
|
|
51
|
+
if(!ts.length){tasksEl.appendChild(el('li',{},'No open tasks.'));return;}
|
|
52
|
+
for(const t of ts){
|
|
53
|
+
const li=document.createElement('li');
|
|
54
|
+
const code=el('code',{},t.elementId||t.userTaskKey);
|
|
55
|
+
li.appendChild(code);
|
|
56
|
+
li.appendChild(document.createTextNode(' — key '+t.userTaskKey));
|
|
57
|
+
if(t.formKey){
|
|
58
|
+
li.appendChild(btn('Open',()=>openForm(t)));
|
|
59
|
+
}else{
|
|
60
|
+
li.appendChild(btn('Complete',()=>complete(t.userTaskKey,{})));
|
|
61
|
+
}
|
|
62
|
+
tasksEl.appendChild(li);
|
|
63
|
+
}
|
|
64
|
+
}).catch(err=>tasksEl.replaceChildren(el('li',{class:'muted'},'Failed to load tasks: '+err.message)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function openForm(t){
|
|
68
|
+
formEl.replaceChildren(el('p',{class:'muted'},'loading form…'));
|
|
69
|
+
const q='formKey='+encodeURIComponent(t.formKey);
|
|
70
|
+
api('/api/form?'+q).then(f=>{
|
|
71
|
+
formEl.replaceChildren();
|
|
72
|
+
if(!f||!f.schema){renderNoForm(t);return;}
|
|
73
|
+
renderForm(t,f.schema);
|
|
74
|
+
}).catch(err=>{formEl.replaceChildren(el('p',{class:'muted'},'Failed to load form: '+err.message));});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function renderNoForm(t){
|
|
78
|
+
formEl.appendChild(el('p',{class:'muted'},'This task has no renderable form.'));
|
|
79
|
+
const bar=el('div',{});
|
|
80
|
+
bar.appendChild(btn('Complete',()=>complete(t.userTaskKey,{})));
|
|
81
|
+
bar.appendChild(btn('Cancel',loadTasks));
|
|
82
|
+
formEl.appendChild(bar);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderForm(t,schema){
|
|
86
|
+
const form=document.createElement('form');
|
|
87
|
+
form.appendChild(el('h2',{},'Task '+(t.elementId||t.userTaskKey)));
|
|
88
|
+
const components=Array.isArray(schema.components)?schema.components:[];
|
|
89
|
+
const inputs=[];
|
|
90
|
+
for(const c of components){
|
|
91
|
+
const built=buildField(c);
|
|
92
|
+
if(!built)continue;
|
|
93
|
+
form.appendChild(built.field);
|
|
94
|
+
if(built.read)inputs.push(built.read);
|
|
95
|
+
}
|
|
96
|
+
const submit=btn('Submit',null);submit.type='submit';
|
|
97
|
+
const bar=el('div',{});bar.appendChild(submit);bar.appendChild(btn('Cancel',loadTasks));
|
|
98
|
+
form.appendChild(bar);
|
|
99
|
+
form.addEventListener('submit',e=>{
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
// Null-prototype: field keys come from the engine-supplied form schema. A component
|
|
102
|
+
// keyed '__proto__'/'constructor' must land as a plain own property (and round-trip
|
|
103
|
+
// through JSON), never mutate a prototype — no prototype pollution in the page runtime.
|
|
104
|
+
const variables=Object.create(null);
|
|
105
|
+
for(const read of inputs){const kv=read();if(kv)variables[kv.key]=kv.value;}
|
|
106
|
+
submit.disabled=true;
|
|
107
|
+
complete(t.userTaskKey,variables).catch(()=>{submit.disabled=false;});
|
|
108
|
+
});
|
|
109
|
+
formEl.appendChild(form);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildField(c){
|
|
113
|
+
const type=c&&c.type;
|
|
114
|
+
// Static (keyless) components: show text as a paragraph, ignore layout-only ones.
|
|
115
|
+
if(type==='text'){return {field:el('p',{class:'muted'},typeof c.text==='string'?c.text:'')};}
|
|
116
|
+
const key=c&&c.key;
|
|
117
|
+
if(!key)return null;
|
|
118
|
+
const wrap=el('div',{class:'field'});
|
|
119
|
+
const label=typeof c.label==='string'&&c.label?c.label:key;
|
|
120
|
+
if(type==='checkbox'){
|
|
121
|
+
const input=document.createElement('input');input.type='checkbox';
|
|
122
|
+
const lab=el('label',{});lab.appendChild(input);lab.appendChild(document.createTextNode(' '+label));
|
|
123
|
+
wrap.appendChild(lab);
|
|
124
|
+
return {field:wrap,read:()=>({key,value:input.checked})};
|
|
125
|
+
}
|
|
126
|
+
const labelEl=el('label',{},label);
|
|
127
|
+
wrap.appendChild(labelEl);
|
|
128
|
+
let input;
|
|
129
|
+
if(type==='textarea'){input=document.createElement('textarea');}
|
|
130
|
+
else if(type==='select'){
|
|
131
|
+
input=document.createElement('select');
|
|
132
|
+
input.appendChild(el('option',{value:''},'—'));
|
|
133
|
+
for(const o of (Array.isArray(c.values)?c.values:[])){
|
|
134
|
+
input.appendChild(el('option',{value:String(o.value)},String(o.label!=null?o.label:o.value)));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
else if(type==='radio'){
|
|
138
|
+
const name='r'+Math.random().toString(36).slice(2);
|
|
139
|
+
const group=el('div',{});
|
|
140
|
+
for(const o of (Array.isArray(c.values)?c.values:[])){
|
|
141
|
+
const rlab=el('label',{});
|
|
142
|
+
const r=document.createElement('input');r.type='radio';r.name=name;r.value=String(o.value);
|
|
143
|
+
rlab.appendChild(r);rlab.appendChild(document.createTextNode(' '+String(o.label!=null?o.label:o.value)));
|
|
144
|
+
group.appendChild(rlab);
|
|
145
|
+
}
|
|
146
|
+
wrap.appendChild(group);
|
|
147
|
+
return {field:wrap,read:()=>{const sel=group.querySelector('input:checked');return sel?{key,value:sel.value}:null;}};
|
|
148
|
+
}
|
|
149
|
+
else{
|
|
150
|
+
input=document.createElement('input');
|
|
151
|
+
input.type=(type==='number')?'number':(type==='datetime'?'datetime-local':'text');
|
|
152
|
+
}
|
|
153
|
+
// Associate the label with the input (for/id) so screen readers announce it on focus.
|
|
154
|
+
const fieldId='f'+Math.random().toString(36).slice(2);
|
|
155
|
+
input.id=fieldId;labelEl.setAttribute('for',fieldId);
|
|
156
|
+
wrap.appendChild(input);
|
|
157
|
+
const isNumber=type==='number';
|
|
158
|
+
return {field:wrap,read:()=>{
|
|
159
|
+
const raw=input.value;
|
|
160
|
+
if(raw===''||raw==null)return null;
|
|
161
|
+
if(isNumber){
|
|
162
|
+
const n=Number(raw);
|
|
163
|
+
// A non-numeric value in a number field is only reachable via tampering (the browser
|
|
164
|
+
// blanks invalid type=number input). Treat it as absent rather than silently submitting
|
|
165
|
+
// NaN, which JSON.stringify serializes as null — quietly changing the submitted value.
|
|
166
|
+
return Number.isFinite(n)?{key,value:n}:null;
|
|
167
|
+
}
|
|
168
|
+
return {key,value:raw};
|
|
169
|
+
}};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function complete(userTaskKey,variables){
|
|
173
|
+
return api('/api/complete',{method:'POST',headers:{'content-type':'application/json'},
|
|
174
|
+
body:JSON.stringify({userTaskKey,variables})}).then(loadTasks);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function el(tag,attrs,text){const e=document.createElement(tag);for(const k in attrs)e.setAttribute(k,attrs[k]);if(text!=null)e.textContent=text;return e;}
|
|
178
|
+
function btn(label,onClick){const b=document.createElement('button');b.type='button';b.textContent=label;if(onClick)b.addEventListener('click',onClick);return b;}
|
|
179
|
+
|
|
180
|
+
loadTasks();
|
|
30
181
|
</script>`;
|
|
31
182
|
}
|
|
32
183
|
/** Mount the enabled surfaces and return their routes. */
|
|
@@ -42,7 +193,7 @@ export function mountSurfaces(ctx, app) {
|
|
|
42
193
|
method: "GET",
|
|
43
194
|
path: base,
|
|
44
195
|
source: "surface:taskInbox",
|
|
45
|
-
handler: () => html(inboxPage(
|
|
196
|
+
handler: () => html(inboxPage()),
|
|
46
197
|
});
|
|
47
198
|
routes.push({
|
|
48
199
|
method: "GET",
|
|
@@ -54,6 +205,29 @@ export function mountSurfaces(ctx, app) {
|
|
|
54
205
|
return json(tasks);
|
|
55
206
|
},
|
|
56
207
|
});
|
|
208
|
+
routes.push({
|
|
209
|
+
method: "GET",
|
|
210
|
+
path: `${base}/api/form`,
|
|
211
|
+
source: "surface:taskInbox",
|
|
212
|
+
handler: async (req) => {
|
|
213
|
+
const formKey = req.query.get("formKey") ?? undefined;
|
|
214
|
+
const formId = req.query.get("formId") ?? undefined;
|
|
215
|
+
// Reject only when *neither* identifier is present. Presence follows getForm's
|
|
216
|
+
// canonical rule (`presentFormIdentifier`: empty/whitespace = absent) so a
|
|
217
|
+
// whitespace-only `?formKey= ` request 400s here instead of slipping past a raw
|
|
218
|
+
// truthiness check and returning a spurious 204. The raw values are still passed
|
|
219
|
+
// through unchanged — resolving a blank key to its `formId` fallback is getForm's
|
|
220
|
+
// single responsibility.
|
|
221
|
+
if (!presentFormIdentifier(formKey) && !presentFormIdentifier(formId))
|
|
222
|
+
return json({ error: "formKey or formId required" }, 400);
|
|
223
|
+
const form = await app.engine.getForm({ formKey, formId });
|
|
224
|
+
// A task whose form can't be resolved returns 204: the client renders the
|
|
225
|
+
// no-form fallback rather than erroring.
|
|
226
|
+
if (!form)
|
|
227
|
+
return noContent();
|
|
228
|
+
return json(form);
|
|
229
|
+
},
|
|
230
|
+
});
|
|
57
231
|
routes.push({
|
|
58
232
|
method: "POST",
|
|
59
233
|
path: `${base}/api/complete`,
|
|
@@ -11,6 +11,13 @@ export interface Route {
|
|
|
11
11
|
export declare function json(body: unknown, status?: number): HttpResponse;
|
|
12
12
|
export declare function html(body: string, status?: number): HttpResponse;
|
|
13
13
|
export declare function text(body: string, status?: number): HttpResponse;
|
|
14
|
+
/**
|
|
15
|
+
* A `204 No Content` response: an empty body and no `content-type`. Use this
|
|
16
|
+
* instead of `json(null, 204)` — the latter emits a `"null"` body that both
|
|
17
|
+
* violates the no-body semantics of 204 and makes a client that parses the
|
|
18
|
+
* body throw. Any client that short-circuits 204 receives no payload here.
|
|
19
|
+
*/
|
|
20
|
+
export declare function noContent(): HttpResponse;
|
|
14
21
|
/**
|
|
15
22
|
* Normalize a manifest-supplied route path: collapse leading slashes to exactly
|
|
16
23
|
* one, drop trailing slashes. Incoming request paths always start with "/", so a
|
|
@@ -10,6 +10,15 @@ export function html(body, status = 200) {
|
|
|
10
10
|
export function text(body, status = 200) {
|
|
11
11
|
return { status, headers: { "content-type": "text/plain; charset=utf-8" }, body };
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* A `204 No Content` response: an empty body and no `content-type`. Use this
|
|
15
|
+
* instead of `json(null, 204)` — the latter emits a `"null"` body that both
|
|
16
|
+
* violates the no-body semantics of 204 and makes a client that parses the
|
|
17
|
+
* body throw. Any client that short-circuits 204 receives no payload here.
|
|
18
|
+
*/
|
|
19
|
+
export function noContent() {
|
|
20
|
+
return { status: 204, headers: {}, body: "" };
|
|
21
|
+
}
|
|
13
22
|
/**
|
|
14
23
|
* Normalize a manifest-supplied route path: collapse leading slashes to exactly
|
|
15
24
|
* one, drop trailing slashes. Incoming request paths always start with "/", so a
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { EngineClient, HostContext } from "./host.ts";
|
|
2
2
|
import { type AppManifest } from "./manifest.ts";
|
|
3
|
-
import type { TemplateSource } from "./modules/templates.ts";
|
|
4
3
|
import { DataLayer } from "./modules/datasource.ts";
|
|
5
4
|
import { type Logger } from "./logger.ts";
|
|
6
5
|
import type { SchedulerDeps } from "./modules/scheduler.ts";
|
|
@@ -32,10 +31,6 @@ export interface CreateUrbanAppOptions {
|
|
|
32
31
|
port?: number;
|
|
33
32
|
/** Which modules to mount (all true by default). */
|
|
34
33
|
mount?: MountFlags;
|
|
35
|
-
/** Programmatic `{{name}}` template source for deploy-time substitution (globs or a
|
|
36
|
-
* `name → content` map). Merged over the manifest's `models.templates` (this wins on
|
|
37
|
-
* collision). See `deployModels`. */
|
|
38
|
-
templates?: TemplateSource;
|
|
39
34
|
/**
|
|
40
35
|
* Injectable timer + clock seam driving every background loop (the cron trigger loops
|
|
41
36
|
* and the instance-tracking reconciler). Defaults to the live scheduler (real timers,
|