@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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// open-user-tasks-store — the canonical engine-truth projection of "which process instances are
|
|
2
|
+
// parked on a human" (ADR 0065, proposal point #1). A framework-level, per-source sidecar (exactly
|
|
3
|
+
// like the lineage store) that materialises the set of currently-open (CREATED-state) user tasks per
|
|
4
|
+
// process instance from engine truth — the same `openUserTasks` / `searchUserTasks({state:"CREATED"})`
|
|
5
|
+
// query the `instanceTracking` reconciler already runs.
|
|
6
|
+
//
|
|
7
|
+
// This ABSORBS nano-workforce's hand-rolled `user_tasks` table: it is the ONE authoritative projection
|
|
8
|
+
// a read model can `EXISTS`-over to derive the "waiting-on-human" status edge, instead of an app
|
|
9
|
+
// hand-maintaining a stored `awaiting_operator` column (the tearing surface of nano-workforce#422).
|
|
10
|
+
//
|
|
11
|
+
// It is framework bookkeeping, so the physical table is `_urban_`-prefixed to stay hidden from the
|
|
12
|
+
// domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables — and it is NEVER
|
|
13
|
+
// app-written. Recording is idempotent and authoritative-per-instance: {@link OpenUserTasksStore.syncInstance}
|
|
14
|
+
// replaces an instance's rows with exactly the engine's current open set, so an answered/closed task
|
|
15
|
+
// disappears on the next poll and the derived edge re-flips by construction (No Drift Surfaces).
|
|
16
|
+
|
|
17
|
+
import type { SqliteDb } from "../host.ts";
|
|
18
|
+
import type { UserTaskSummary } from "../host.ts";
|
|
19
|
+
import { type Clock, systemClock } from "./lineage-store.ts";
|
|
20
|
+
|
|
21
|
+
/** The open-user-tasks projection table. Framework bookkeeping, so `_urban_`-prefixed to stay hidden
|
|
22
|
+
* from the domain model / DB Manager (see `SqliteGateway.schema()`), like the lineage tables. */
|
|
23
|
+
export const OPEN_USER_TASKS_TABLE = "_urban_open_user_tasks";
|
|
24
|
+
|
|
25
|
+
/** The stable DSL projection NAME a `defineReadModel` `exists(...)` references (registered in the
|
|
26
|
+
* `projectionRegistry`, mapped to {@link OPEN_USER_TASKS_TABLE}). The DSL name is unprefixed so a read
|
|
27
|
+
* model reads `EXISTS(urban_open_user_tasks WHERE process_instance_key = base.process_instance_key)`. */
|
|
28
|
+
export const OPEN_USER_TASKS_PROJECTION = "urban_open_user_tasks";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The canonical open-user-tasks DDL — the single source of truth applied by
|
|
32
|
+
* {@link OpenUserTasksStore.ensureSchema}, mirrored verbatim by the boot migration
|
|
33
|
+
* `db/migrations/006_urban_open_user_tasks.sql` (a drift-guard test asserts they match).
|
|
34
|
+
*
|
|
35
|
+
* Forward-only and additive. Keyed by `user_task_key` so a re-record of the same open task is a no-op;
|
|
36
|
+
* `process_instance_key` carries an index so a read model's correlated `EXISTS(... WHERE
|
|
37
|
+
* process_instance_key = …)` and the reconciler's per-instance sync read only that instance's rows.
|
|
38
|
+
*/
|
|
39
|
+
export const OPEN_USER_TASKS_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${OPEN_USER_TASKS_TABLE} (
|
|
40
|
+
process_instance_key TEXT NOT NULL,
|
|
41
|
+
user_task_key TEXT NOT NULL,
|
|
42
|
+
element_id TEXT,
|
|
43
|
+
created_at TEXT NOT NULL,
|
|
44
|
+
PRIMARY KEY (user_task_key)
|
|
45
|
+
);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_${OPEN_USER_TASKS_TABLE}_instance ON ${OPEN_USER_TASKS_TABLE} (process_instance_key);`;
|
|
47
|
+
|
|
48
|
+
export interface OpenUserTasksStoreOptions {
|
|
49
|
+
readonly clock?: Clock;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One projected open-user-task row, as {@link OpenUserTasksStore.openTasks} returns it. */
|
|
53
|
+
export interface OpenUserTaskRow {
|
|
54
|
+
readonly processInstanceKey: string;
|
|
55
|
+
readonly userTaskKey: string;
|
|
56
|
+
readonly elementId?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface OpenTaskDbRow {
|
|
60
|
+
process_instance_key: string;
|
|
61
|
+
user_task_key: string;
|
|
62
|
+
element_id: string | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The per-source sidecar behind the `urban_open_user_tasks` projection. A thin, idempotent
|
|
67
|
+
* persistence + query layer over the engine's open-user-task truth; it interprets no app schema.
|
|
68
|
+
*/
|
|
69
|
+
export class OpenUserTasksStore {
|
|
70
|
+
readonly #db: SqliteDb;
|
|
71
|
+
readonly #clock: Clock;
|
|
72
|
+
|
|
73
|
+
constructor(db: SqliteDb, options: OpenUserTasksStoreOptions = {}) {
|
|
74
|
+
this.#db = db;
|
|
75
|
+
this.#clock = options.clock ?? systemClock;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Apply the canonical DDL (idempotent). Callers whose app DataLayer applies the boot migration
|
|
79
|
+
* `db/migrations/006_urban_open_user_tasks.sql` do not need this; the runtime calls it so the store
|
|
80
|
+
* is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
|
|
81
|
+
ensureSchema(): void {
|
|
82
|
+
this.#db.exec(OPEN_USER_TASKS_SCHEMA_SQL);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Record one open user task for an instance, idempotently. A re-record of the same `userTaskKey` is
|
|
87
|
+
* a no-op. A blank `processInstanceKey` or `userTaskKey` is ignored. Prefer {@link syncInstance} to
|
|
88
|
+
* record an instance's WHOLE current open set (which also retires closed tasks); this single-row
|
|
89
|
+
* form is the idempotent building block.
|
|
90
|
+
*/
|
|
91
|
+
recordOpenTask(processInstanceKey: string, task: UserTaskSummary): boolean {
|
|
92
|
+
if (!processInstanceKey || !task.userTaskKey) return false;
|
|
93
|
+
const { changes } = this.#db.run(
|
|
94
|
+
`INSERT OR IGNORE INTO ${OPEN_USER_TASKS_TABLE}
|
|
95
|
+
(process_instance_key, user_task_key, element_id, created_at)
|
|
96
|
+
VALUES (?, ?, ?, ?)`,
|
|
97
|
+
[processInstanceKey, task.userTaskKey, task.elementId ?? null, new Date(this.#clock.now()).toISOString()],
|
|
98
|
+
);
|
|
99
|
+
return changes > 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Make the projection reflect an instance's CURRENT open set exactly: insert the engine's open tasks
|
|
104
|
+
* and delete any previously-projected task for the instance that is no longer open. This is the
|
|
105
|
+
* authoritative record from engine truth (`openUserTasks({ processInstanceKey })`), the operation the
|
|
106
|
+
* reconciler drives each poll — so an answered/closed task disappears and the derived
|
|
107
|
+
* "waiting-on-human" edge re-flips by construction (nano-workforce#422). Idempotent: syncing the same
|
|
108
|
+
* open set twice leaves the table unchanged. A blank `processInstanceKey` is ignored.
|
|
109
|
+
*/
|
|
110
|
+
syncInstance(processInstanceKey: string, openTasks: readonly UserTaskSummary[]): void {
|
|
111
|
+
if (!processInstanceKey) return;
|
|
112
|
+
// Wrap the insert+delete replace in a SAVEPOINT so the whole sync is atomic: no reader observes a
|
|
113
|
+
// transient mixed state mid-sync, and a mid-loop failure rolls back cleanly instead of leaving the
|
|
114
|
+
// instance with a torn open set. SAVEPOINT (not BEGIN) so this also composes when called inside an
|
|
115
|
+
// existing transaction, e.g. a reconciler poll that batches many instances.
|
|
116
|
+
this.#db.exec("SAVEPOINT urban_sync_open_tasks");
|
|
117
|
+
try {
|
|
118
|
+
const keep = new Set<string>();
|
|
119
|
+
for (const task of openTasks) {
|
|
120
|
+
if (!task.userTaskKey) continue;
|
|
121
|
+
keep.add(task.userTaskKey);
|
|
122
|
+
this.recordOpenTask(processInstanceKey, task);
|
|
123
|
+
}
|
|
124
|
+
const existing = this.#db.all<{ user_task_key: string }>(
|
|
125
|
+
`SELECT user_task_key FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ?`,
|
|
126
|
+
[processInstanceKey],
|
|
127
|
+
);
|
|
128
|
+
for (const row of existing) {
|
|
129
|
+
if (!keep.has(row.user_task_key)) {
|
|
130
|
+
this.#db.run(`DELETE FROM ${OPEN_USER_TASKS_TABLE} WHERE user_task_key = ?`, [row.user_task_key]);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
this.#db.exec("RELEASE urban_sync_open_tasks");
|
|
134
|
+
} catch (err) {
|
|
135
|
+
this.#db.exec("ROLLBACK TO urban_sync_open_tasks");
|
|
136
|
+
this.#db.exec("RELEASE urban_sync_open_tasks");
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Retire every projected open task for an instance (e.g. it terminated). Idempotent. */
|
|
142
|
+
clearInstance(processInstanceKey: string): void {
|
|
143
|
+
if (!processInstanceKey) return;
|
|
144
|
+
this.#db.run(`DELETE FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ?`, [processInstanceKey]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** True iff the instance currently has any open user task — the "parked on a human" predicate. */
|
|
148
|
+
hasOpenTask(processInstanceKey: string): boolean {
|
|
149
|
+
if (!processInstanceKey) return false;
|
|
150
|
+
// Existence predicate: `SELECT 1 … LIMIT 1` short-circuits on the first matching row rather than
|
|
151
|
+
// counting every open task for the instance.
|
|
152
|
+
const rows = this.#db.all<{ one: number }>(
|
|
153
|
+
`SELECT 1 AS one FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ? LIMIT 1`,
|
|
154
|
+
[processInstanceKey],
|
|
155
|
+
);
|
|
156
|
+
return rows.length > 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The projected open tasks for an instance, in insertion order. */
|
|
160
|
+
openTasks(processInstanceKey: string): OpenUserTaskRow[] {
|
|
161
|
+
const rows = this.#db.all<OpenTaskDbRow>(
|
|
162
|
+
`SELECT process_instance_key, user_task_key, element_id
|
|
163
|
+
FROM ${OPEN_USER_TASKS_TABLE} WHERE process_instance_key = ? ORDER BY rowid`,
|
|
164
|
+
[processInstanceKey],
|
|
165
|
+
);
|
|
166
|
+
return rows.map((r) => ({
|
|
167
|
+
processInstanceKey: r.process_instance_key,
|
|
168
|
+
userTaskKey: r.user_task_key,
|
|
169
|
+
...(r.element_id ? { elementId: r.element_id } : {}),
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -1950,3 +1950,102 @@ test("the nested serve declines (does not shadow) a same-path trigger route regi
|
|
|
1950
1950
|
assert.equal(hook.status, 202);
|
|
1951
1951
|
assert.deepEqual(JSON.parse(hook.body ?? "{}"), { queued: true });
|
|
1952
1952
|
});
|
|
1953
|
+
|
|
1954
|
+
// ── engine-declared forms in a dataGrid detail (ADR 0026 / #457) ────────────
|
|
1955
|
+
// The pages surface exposes the SAME two seams the taskInbox surface uses:
|
|
1956
|
+
// GET /app/actions/form?formKey=… → resolve a deployed .form's form-js schema
|
|
1957
|
+
// POST /app/actions/complete → complete the row's user task
|
|
1958
|
+
// so one grid can render the several different typed forms a heterogeneous task
|
|
1959
|
+
// inbox carries, instead of duplicating each into a page-local `detail.form`.
|
|
1960
|
+
|
|
1961
|
+
/** A recording engine for the form-resolution + completion seams. `resolveForm`
|
|
1962
|
+
* supplies the getForm result while the wrapper still records every call, so a test
|
|
1963
|
+
* can assert BOTH the resolved schema and the exact `{formKey, formId}` the shared
|
|
1964
|
+
* presence gate passed through. */
|
|
1965
|
+
function formEngine(
|
|
1966
|
+
resolveForm: (input: { formKey?: string; formId?: string }) => Awaited<ReturnType<EngineClient["getForm"]>> = () => null,
|
|
1967
|
+
): {
|
|
1968
|
+
engine: EngineClient;
|
|
1969
|
+
formCalls: { formKey?: string; formId?: string }[];
|
|
1970
|
+
completeCalls: { userTaskKey: string; variables?: Record<string, unknown> }[];
|
|
1971
|
+
} {
|
|
1972
|
+
const formCalls: { formKey?: string; formId?: string }[] = [];
|
|
1973
|
+
const completeCalls: { userTaskKey: string; variables?: Record<string, unknown> }[] = [];
|
|
1974
|
+
const { engine } = fakeEngine();
|
|
1975
|
+
const withSeams: EngineClient = {
|
|
1976
|
+
...engine,
|
|
1977
|
+
async getForm(input: { formKey?: string; formId?: string }) {
|
|
1978
|
+
formCalls.push(input);
|
|
1979
|
+
return resolveForm(input);
|
|
1980
|
+
},
|
|
1981
|
+
async completeUserTask(userTaskKey: string, variables?: Record<string, unknown>) {
|
|
1982
|
+
completeCalls.push({ userTaskKey, variables });
|
|
1983
|
+
},
|
|
1984
|
+
};
|
|
1985
|
+
return { engine: withSeams, formCalls, completeCalls };
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
function formRouter(engine: EngineClient) {
|
|
1989
|
+
const routes = createPagesRoutes({ pagesDir: "pages", homePage: "home", sourceName: "app" }, {
|
|
1990
|
+
db: fakeDb(),
|
|
1991
|
+
engine,
|
|
1992
|
+
readPage: async () => JSON.stringify({ title: "Home", nodes: [] }),
|
|
1993
|
+
listPages: async () => ["home"],
|
|
1994
|
+
});
|
|
1995
|
+
return makeRouter(routes);
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
test("GET /app/actions/form resolves a row's engine formKey to its form-js schema", async () => {
|
|
1999
|
+
const schema = { type: "default", components: [{ type: "textfield", key: "note" }] };
|
|
2000
|
+
const { engine, formCalls } = formEngine((input) => ({ formKey: input.formKey, schema }));
|
|
2001
|
+
const res = await formRouter(engine)(req("GET", "/app/actions/form", { query: "formKey=form-123" }));
|
|
2002
|
+
assert.equal(res.status, 200);
|
|
2003
|
+
assert.deepEqual(JSON.parse(res.body ?? "{}").schema, schema);
|
|
2004
|
+
assert.deepEqual(formCalls, [{ formKey: "form-123", formId: undefined }]);
|
|
2005
|
+
});
|
|
2006
|
+
|
|
2007
|
+
test("GET /app/actions/form 400s when neither formKey nor formId is present (shared presence gate)", async () => {
|
|
2008
|
+
const { engine } = formEngine();
|
|
2009
|
+
const res = await formRouter(engine)(req("GET", "/app/actions/form"));
|
|
2010
|
+
assert.equal(res.status, 400);
|
|
2011
|
+
const blank = await formRouter(engine)(req("GET", "/app/actions/form", { query: "formKey=%20%20" }));
|
|
2012
|
+
assert.equal(blank.status, 400, "a whitespace-only formKey is absent per the host.ts presence rule");
|
|
2013
|
+
});
|
|
2014
|
+
|
|
2015
|
+
test("GET /app/actions/form 204s when the form can't be resolved (client falls back to bare completion)", async () => {
|
|
2016
|
+
// getForm returns null (default) → 204, matching the taskInbox no-form fallback.
|
|
2017
|
+
const { engine } = formEngine();
|
|
2018
|
+
const res = await formRouter(engine)(req("GET", "/app/actions/form", { query: "formKey=missing" }));
|
|
2019
|
+
assert.equal(res.status, 204);
|
|
2020
|
+
assert.equal(res.body ?? "", "");
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
test("POST /app/actions/complete completes the row's user task with the submitted variables", async () => {
|
|
2024
|
+
const { engine, completeCalls } = formEngine();
|
|
2025
|
+
const res = await formRouter(engine)(
|
|
2026
|
+
req("POST", "/app/actions/complete", { body: { userTaskKey: "ut-9", variables: { note: "ok" } } }),
|
|
2027
|
+
);
|
|
2028
|
+
assert.equal(res.status, 200);
|
|
2029
|
+
assert.deepEqual(JSON.parse(res.body ?? "{}"), { ok: true });
|
|
2030
|
+
assert.deepEqual(completeCalls, [{ userTaskKey: "ut-9", variables: { note: "ok" } }]);
|
|
2031
|
+
});
|
|
2032
|
+
|
|
2033
|
+
test("POST /app/actions/complete supports the no-form bare completion (empty variables)", async () => {
|
|
2034
|
+
const { engine, completeCalls } = formEngine();
|
|
2035
|
+
const res = await formRouter(engine)(req("POST", "/app/actions/complete", { body: { userTaskKey: "ut-1" } }));
|
|
2036
|
+
assert.equal(res.status, 200);
|
|
2037
|
+
assert.deepEqual(completeCalls, [{ userTaskKey: "ut-1", variables: undefined }]);
|
|
2038
|
+
});
|
|
2039
|
+
|
|
2040
|
+
test("POST /app/actions/complete 400s without a userTaskKey", async () => {
|
|
2041
|
+
const { engine, completeCalls } = formEngine();
|
|
2042
|
+
const res = await formRouter(engine)(req("POST", "/app/actions/complete", { body: { variables: {} } }));
|
|
2043
|
+
assert.equal(res.status, 400);
|
|
2044
|
+
assert.equal(completeCalls.length, 0, "no completion is attempted for a keyless body");
|
|
2045
|
+
});
|
|
2046
|
+
|
|
2047
|
+
test("the pages shell embeds the shared form-js renderer (NanoFormJs) for engine forms", async () => {
|
|
2048
|
+
const res = await dispatch("GET", "/");
|
|
2049
|
+
assert.match(res.body ?? "", /NanoFormJs/, "the shell exposes the shared renderer as a global");
|
|
2050
|
+
assert.match(res.body ?? "", /function renderForm/, "the shared renderer is embedded, not forked");
|
|
2051
|
+
});
|
|
@@ -31,6 +31,8 @@ import type { EngineClient } from "../host.ts";
|
|
|
31
31
|
import { html, json, type Route } from "../router.ts";
|
|
32
32
|
import { cancelInstanceReconciling, type CancelInstanceResult } from "./cancel.ts";
|
|
33
33
|
import { apiDocsPath } from "./api.ts";
|
|
34
|
+
import { FORMJS_JS } from "./formjs.gen.ts";
|
|
35
|
+
import { completeUserTaskResponse, resolveFormResponse } from "./forms.ts";
|
|
34
36
|
import { quoteIdent } from "./gateway.ts";
|
|
35
37
|
import { RENDERER_JS } from "./runtime.gen.ts";
|
|
36
38
|
|
|
@@ -545,6 +547,28 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
|
|
|
545
547
|
},
|
|
546
548
|
});
|
|
547
549
|
|
|
550
|
+
// ── GET /app/actions/form & POST /app/actions/complete (ADR 0026, #457) ───
|
|
551
|
+
// Engine-declared forms in a `dataGrid` detail drawer. A grid whose `detail`
|
|
552
|
+
// opts into `engineForm` resolves the selected row's `form_key` here and
|
|
553
|
+
// completes the row's user task here — the SAME two seams the `taskInbox`
|
|
554
|
+
// surface uses, reused (not forked) via `forms.ts` so the resolution gate and
|
|
555
|
+
// the completion contract can't drift between the two surfaces. This closes the
|
|
556
|
+
// form-duplication drift class: the grid renders the authoritative deployed
|
|
557
|
+
// `.form` rather than a hand-authored page-local `detail.form` copy.
|
|
558
|
+
routes.push({
|
|
559
|
+
method: "GET",
|
|
560
|
+
path: "/app/actions/form",
|
|
561
|
+
source: "surface:pages",
|
|
562
|
+
handler: (req) =>
|
|
563
|
+
resolveFormResponse(engine, req.query.get("formKey") ?? undefined, req.query.get("formId") ?? undefined),
|
|
564
|
+
});
|
|
565
|
+
routes.push({
|
|
566
|
+
method: "POST",
|
|
567
|
+
path: "/app/actions/complete",
|
|
568
|
+
source: "surface:pages",
|
|
569
|
+
handler: async (req) => completeUserTaskResponse(engine, await req.text()),
|
|
570
|
+
});
|
|
571
|
+
|
|
548
572
|
// ── nested (multi-appView) sidecars (#442) ──────────────────────────────
|
|
549
573
|
// An app with more than one `appView` cannot share the four FLAT sidecar names above:
|
|
550
574
|
// each view ships its own SUBDIR sidecar — e.g. `pages/cockpit.page.json` →
|
|
@@ -668,6 +692,7 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
|
|
|
668
692
|
</head>
|
|
669
693
|
<body>${apiDocsBadge}
|
|
670
694
|
<main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
|
|
695
|
+
<script>${FORMJS_JS}</script>
|
|
671
696
|
<script type="module" src=".${RUNTIME_JS_PATH}"></script>
|
|
672
697
|
</body>
|
|
673
698
|
</html>`;
|
|
@@ -736,6 +761,19 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
|
|
|
736
761
|
.pc-field-check input { width:auto; padding:0; accent-color:var(--nano-accent); cursor:pointer; }
|
|
737
762
|
.pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
|
|
738
763
|
.pc-btn:disabled { opacity:.5; cursor:default; }
|
|
764
|
+
/* The shared engine-form renderer (NanoFormJs, ADR 0026/#457) styled to match the
|
|
765
|
+
page runtime's native detail controls. njf-* are its stable class names; both
|
|
766
|
+
this surface and the taskInbox page style the same renderer output. */
|
|
767
|
+
.njf-form { display:flex; flex-direction:column; gap:.4rem; margin-top:.5rem; }
|
|
768
|
+
.njf-heading { font-size:1rem; margin:0 0 .25rem; }
|
|
769
|
+
.njf-text { color:var(--nano-text-muted); margin:.25rem 0; }
|
|
770
|
+
.njf-field { display:flex; flex-direction:column; gap:.25rem; }
|
|
771
|
+
.njf-field label { font-size:.8rem; color:var(--nano-text-muted); }
|
|
772
|
+
.njf-field input:not([type=checkbox]):not([type=radio]), .njf-field textarea, .njf-field select { padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); max-width:24rem; }
|
|
773
|
+
.njf-actions { display:flex; gap:.5rem; margin-top:.4rem; }
|
|
774
|
+
.njf-actions button { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
|
|
775
|
+
.njf-msg { font-size:.85rem; margin-top:.35rem; }
|
|
776
|
+
.njf-msg.err { color:var(--nano-danger); }
|
|
739
777
|
.pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
|
|
740
778
|
.pc-msg.err { color:var(--nano-danger); }
|
|
741
779
|
.pc-msg.ok { color:var(--nano-ok); }
|