@worca/app 1.3.0 → 1.4.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 +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/shared/forms/answer.mjs
|
|
2
|
+
// Turning raw input into the answer an agent resumes with (spec §5 gate 3, D10), and
|
|
3
|
+
// finding the files an ask references (§7). The browser calls collectAnswer for live
|
|
4
|
+
// feedback; the server calls the SAME function and its verdict is the one that counts.
|
|
5
|
+
import { validate, resolveAnswerSchema } from './schema.mjs';
|
|
6
|
+
import { resolvePath } from './paths.mjs';
|
|
7
|
+
import { walkLayout, visibleFields } from './layout.mjs';
|
|
8
|
+
import { widgetClass } from './catalog.mjs';
|
|
9
|
+
|
|
10
|
+
const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
11
|
+
const clone = (v) => JSON.parse(JSON.stringify(v));
|
|
12
|
+
const isMissing = (v) => v === undefined || v === null || v === '';
|
|
13
|
+
/** Star-slash-star, spelled without the literal: that sequence would close a block comment. */
|
|
14
|
+
const ANY_MIME = ['*', '*'].join('/');
|
|
15
|
+
const rowsOf = (item, data) => {
|
|
16
|
+
const rows = typeof item.bind === 'string' ? resolvePath(item.bind, { data }) : [];
|
|
17
|
+
return Array.isArray(rows) ? rows.filter(isObject) : [];
|
|
18
|
+
};
|
|
19
|
+
/** The ids a row widget can answer with. Gate 1 makes `id` a required text where it can see the
|
|
20
|
+
* row schema; opaque rows (C3) arrive unchecked, and a row with no text id is nobody's item. */
|
|
21
|
+
const idsOf = (item, data) => rowsOf(item, data).map((r) => (Object.hasOwn(r, 'id') ? r.id : undefined))
|
|
22
|
+
.filter((id) => typeof id === 'string' && id !== '');
|
|
23
|
+
|
|
24
|
+
/** Input items by field, effective (post-fallback) form. */
|
|
25
|
+
function inputItems(layout) {
|
|
26
|
+
const out = new Map();
|
|
27
|
+
walkLayout(layout, (raw, eff) => {
|
|
28
|
+
if (eff && widgetClass(eff.widget, eff) === 'input' && typeof eff.field === 'string' && !out.has(eff.field)) out.set(eff.field, eff);
|
|
29
|
+
});
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Visibility depends on values and hidden values are dropped, so settle to a fixpoint. It always
|
|
34
|
+
* ends: a round either drops nothing (done) or leaves strictly fewer values. A fixed cap (it was
|
|
35
|
+
* 6) let the tail of a longer `when` chain reach the agent although it was hidden. */
|
|
36
|
+
function settle(layout, values) {
|
|
37
|
+
let cur = values;
|
|
38
|
+
for (;;) {
|
|
39
|
+
const vis = new Set(visibleFields(layout, cur));
|
|
40
|
+
const next = Object.fromEntries(Object.entries(cur).filter(([k]) => vis.has(k)));
|
|
41
|
+
if (Object.keys(next).length === Object.keys(cur).length) return { values: next, visible: vis };
|
|
42
|
+
cur = next;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function candidate(item, schema, data, required) {
|
|
47
|
+
if (schema.default !== undefined) return clone(schema.default);
|
|
48
|
+
const need = required || (schema.minItems || 0) > 0;
|
|
49
|
+
if (item.widget === 'rank') return idsOf(item, data);
|
|
50
|
+
if (item.widget === 'review-list') {
|
|
51
|
+
const verdict = schema.items.properties.verdict;
|
|
52
|
+
const first = verdict.default !== undefined ? verdict.default : verdict.enum[0];
|
|
53
|
+
return idsOf(item, data).map((id) => ({ id, verdict: first }));
|
|
54
|
+
}
|
|
55
|
+
if (!need) return undefined;
|
|
56
|
+
if (schema.type === 'array') {
|
|
57
|
+
const pool = (schema.items && schema.items.enum) || idsOf(item, data);
|
|
58
|
+
return pool.slice(0, Math.max(1, schema.minItems || 0));
|
|
59
|
+
}
|
|
60
|
+
if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[0];
|
|
61
|
+
if (Array.isArray(item.suggest) && item.suggest.length) return item.suggest[0];
|
|
62
|
+
if (item.widget === 'table-select' || item.widget === 'gallery') return idsOf(item, data)[0];
|
|
63
|
+
if (schema.type === 'boolean') return false;
|
|
64
|
+
if (schema.type === 'number' || schema.type === 'integer') return schema.minimum !== undefined ? schema.minimum : 0;
|
|
65
|
+
return undefined; // free text with no default: the form author must supply one (gate 1 `bad-auto`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** D10: the answer auto mode (`--yes`) gives. Per field: `default`, else `defaultFrom`,
|
|
69
|
+
* else the first choice, else the widget's natural value. Hidden fields are dropped. */
|
|
70
|
+
export function autoAnswer(def, data) {
|
|
71
|
+
const schema = resolveAnswerSchema(def.answer, data);
|
|
72
|
+
const required = new Set(schema.required || []);
|
|
73
|
+
const values = {};
|
|
74
|
+
for (const [field, item] of inputItems(def.layout)) {
|
|
75
|
+
const s = isObject(schema.properties) && Object.hasOwn(schema.properties, field) ? schema.properties[field] : null;
|
|
76
|
+
if (!s) continue;
|
|
77
|
+
const v = candidate(item, s, data, required.has(field));
|
|
78
|
+
if (!isMissing(v)) values[field] = v;
|
|
79
|
+
}
|
|
80
|
+
return settle(def.layout, values).values;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stripItem(schema, v) {
|
|
84
|
+
if (schema.type === 'array' && Array.isArray(v) && schema.items) return v.map((x) => stripItem(schema.items, x));
|
|
85
|
+
if (schema.type === 'object' && isObject(v) && isObject(schema.properties)) {
|
|
86
|
+
return Object.fromEntries(Object.entries(v).filter(([k, x]) => Object.hasOwn(schema.properties, k) && !isMissing(x)).map(([k, x]) => [k, stripItem(schema.properties[k], x)]));
|
|
87
|
+
}
|
|
88
|
+
return v;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Gate 3 core. Drops unknown keys, empty values and `when`-hidden fields, makes
|
|
92
|
+
* `required` apply only to visible fields, then validates. `resolvedSchema` is
|
|
93
|
+
* `resolveAnswerSchema(def.answer, data)`. → { values, errors } */
|
|
94
|
+
export function collectAnswer(def, resolvedSchema, rawValues) {
|
|
95
|
+
const props = isObject(resolvedSchema) && isObject(resolvedSchema.properties) ? resolvedSchema.properties : {};
|
|
96
|
+
// Own declared keys only, and built with fromEntries: `known[k] = v` would turn a posted
|
|
97
|
+
// "__proto__" into the object's prototype, and `when` would then read values nobody sent.
|
|
98
|
+
const known = Object.fromEntries(Object.entries(isObject(rawValues) ? rawValues : {})
|
|
99
|
+
.filter(([k, v]) => Object.hasOwn(props, k) && !isMissing(v))
|
|
100
|
+
.map(([k, v]) => [k, stripItem(props[k], clone(v))]));
|
|
101
|
+
const { values, visible } = settle(def.layout, known);
|
|
102
|
+
const required = isObject(resolvedSchema) && Array.isArray(resolvedSchema.required) ? resolvedSchema.required : [];
|
|
103
|
+
const errors = validate({ ...resolvedSchema, required: required.filter((f) => visible.has(f)) }, values).errors;
|
|
104
|
+
// A reorder and a per-item review name each item once. That is the widget's rule, and no
|
|
105
|
+
// schema keyword can state it for rows: `uniqueItems` compares whole objects, so two verdicts
|
|
106
|
+
// for one id would reach the agent. Reported once, also where `uniqueItems` already said so.
|
|
107
|
+
for (const [field, item] of inputItems(def.layout)) {
|
|
108
|
+
const v = Object.hasOwn(values, field) ? values[field] : undefined;
|
|
109
|
+
if ((item.widget !== 'rank' && item.widget !== 'review-list') || !Array.isArray(v)) continue;
|
|
110
|
+
const ids = v.map((x) => (isObject(x) ? x.id : x));
|
|
111
|
+
if (new Set(ids).size !== ids.length && !errors.some((e) => e.path === field && e.code === 'unique')) {
|
|
112
|
+
errors.push({ path: field, code: 'unique', message: 'Each item may appear only once.' });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { values, errors };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Gate 2 core (spec §5): is this run-time `data` askable? It must pass the data schema, and
|
|
119
|
+
* the auto answer built from it must pass gate 3 — so D10 holds for the data an agent really
|
|
120
|
+
* wrote, not only for the form's `example`. An empty `enumFrom` on a required choice fails
|
|
121
|
+
* here, and so does a required field whose `defaultFrom` source the agent left out. → { ok, errors } */
|
|
122
|
+
export function checkAskData(def, data) {
|
|
123
|
+
const shape = validate(def.data, data);
|
|
124
|
+
if (!shape.ok) return { ok: false, errors: shape.errors.map((e) => ({ ...e, path: e.path ? `data.${e.path}` : 'data' })) };
|
|
125
|
+
const auto = collectAnswer(def, resolveAnswerSchema(def.answer, data), autoAnswer(def, data));
|
|
126
|
+
const errors = auto.errors.map((e) => ({ path: `answer.${e.path}`, code: 'bad-auto',
|
|
127
|
+
message: `unattended runs could not answer "${e.path}" with this data: ${e.message}` }));
|
|
128
|
+
return { ok: errors.length === 0, errors };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Every `type: 'file'` value in an agent's data → [{ path, rel, accept }]. `path` is
|
|
132
|
+
* a concrete location ('data.images[0].file', the path a gate-2 error names); `rel` is the
|
|
133
|
+
* run-relative path the agent wrote. Document order: depth-first, array index ascending —
|
|
134
|
+
* P2 uses the position as the served file index. */
|
|
135
|
+
export function fileRefs(dataSchema, data) {
|
|
136
|
+
const out = [];
|
|
137
|
+
(function walk(schema, v, path) {
|
|
138
|
+
if (!isObject(schema) || v === undefined || v === null) return;
|
|
139
|
+
if (schema.type === 'file') { if (typeof v === 'string') out.push({ path, rel: v, accept: Array.isArray(schema.accept) ? schema.accept : [] }); return; }
|
|
140
|
+
if (schema.type === 'array' && Array.isArray(v)) v.forEach((x, i) => walk(schema.items, x, `${path}[${i}]`));
|
|
141
|
+
if (schema.type === 'object' && isObject(v) && isObject(schema.properties)) {
|
|
142
|
+
for (const [k, s] of Object.entries(schema.properties)) if (Object.hasOwn(v, k)) walk(s, v[k], `${path}.${k}`);
|
|
143
|
+
}
|
|
144
|
+
})(dataSchema, data, 'data');
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The mime patterns a form may display, from its data SCHEMA alone (no instance data): what
|
|
149
|
+
* the plugin consent screen lists before anything is installed or run. Deduped and sorted; a
|
|
150
|
+
* `file` with no `accept` contributes the any-type pattern. */
|
|
151
|
+
export function fileAccepts(dataSchema) {
|
|
152
|
+
const out = new Set();
|
|
153
|
+
(function walk(schema) {
|
|
154
|
+
if (!isObject(schema)) return;
|
|
155
|
+
if (schema.type === 'file') {
|
|
156
|
+
const accept = Array.isArray(schema.accept) && schema.accept.length ? schema.accept : [ANY_MIME];
|
|
157
|
+
for (const a of accept) out.add(a);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (schema.items) walk(schema.items);
|
|
161
|
+
if (isObject(schema.properties)) for (const s of Object.values(schema.properties)) walk(s);
|
|
162
|
+
})(dataSchema);
|
|
163
|
+
return [...out].sort();
|
|
164
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// src/shared/forms/catalog.mjs
|
|
2
|
+
// The host-owned widget catalog for agent ask forms (ask-forms spec §6.1). A form
|
|
3
|
+
// names widgets by string; only names listed here ever render. Pure and isomorphic:
|
|
4
|
+
// the registry, the plugin validator, the server and the browser read the same tables.
|
|
5
|
+
|
|
6
|
+
/** Bumped when a widget is added. A layout item may say `requires: { askCatalog: n }`. */
|
|
7
|
+
export const ASK_CATALOG_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
export const INPUT_WIDGETS = Object.freeze(['text', 'textarea', 'number', 'slider', 'toggle', 'date',
|
|
10
|
+
'select', 'multiselect', 'rank', 'table-select', 'review-list', 'gallery']);
|
|
11
|
+
export const DISPLAY_WIDGETS = Object.freeze(['markdown', 'callout', 'image', 'gallery', 'compare', 'pdf',
|
|
12
|
+
'code', 'diff', 'table', 'json', 'file-list', 'media']);
|
|
13
|
+
export const LAYOUT_WIDGETS = Object.freeze(['group', 'columns', 'tabs']);
|
|
14
|
+
|
|
15
|
+
/** Limits, verbatim from the spec (§3, §7). */
|
|
16
|
+
export const ASK_LIMITS = Object.freeze({
|
|
17
|
+
formsPerAgent: 8, askBlockBytes: 65536, filesPerAsk: 24,
|
|
18
|
+
fileBytes: 26214400, askBytes: 104857600, dataBytes: 262144,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** The layout-item vocabulary (spec §6.2): the keys every item may carry, then each widget's own.
|
|
22
|
+
* Gate 1 refuses anything else, and the renderer reads nothing else — one table, no drift. */
|
|
23
|
+
export const COMMON_ITEM_KEYS = Object.freeze(['widget', 'field', 'bind', 'label', 'help', 'when', 'requires', 'fallback']);
|
|
24
|
+
export const LAYOUT_ITEM_KEYS = Object.freeze({
|
|
25
|
+
text: Object.freeze(['placeholder', 'mono']),
|
|
26
|
+
textarea: Object.freeze(['placeholder', 'rows']),
|
|
27
|
+
number: Object.freeze(['unit', 'placeholder']),
|
|
28
|
+
slider: Object.freeze(['unit', 'minLabel', 'maxLabel']),
|
|
29
|
+
toggle: Object.freeze([]),
|
|
30
|
+
date: Object.freeze([]),
|
|
31
|
+
select: Object.freeze(['style', 'options', 'labels', 'descriptions', 'tones', 'suggest']),
|
|
32
|
+
multiselect: Object.freeze(['style', 'options', 'labels', 'descriptions']),
|
|
33
|
+
rank: Object.freeze(['titleKey', 'metaKey']),
|
|
34
|
+
'table-select': Object.freeze(['columns']),
|
|
35
|
+
'review-list': Object.freeze(['titleKey', 'bodyKey', 'metaKey', 'labels', 'tones', 'notePlaceholder']),
|
|
36
|
+
gallery: Object.freeze(['captionKey', 'fileKey']),
|
|
37
|
+
markdown: Object.freeze([]),
|
|
38
|
+
callout: Object.freeze(['text', 'title', 'tone']),
|
|
39
|
+
image: Object.freeze(['caption']),
|
|
40
|
+
compare: Object.freeze(['before', 'after', 'beforeLabel', 'afterLabel']),
|
|
41
|
+
pdf: Object.freeze([]),
|
|
42
|
+
media: Object.freeze([]),
|
|
43
|
+
code: Object.freeze(['name', 'lang']),
|
|
44
|
+
diff: Object.freeze(['name']),
|
|
45
|
+
table: Object.freeze(['columns']),
|
|
46
|
+
json: Object.freeze([]),
|
|
47
|
+
'file-list': Object.freeze(['fileKey', 'noteKey']),
|
|
48
|
+
group: Object.freeze(['title', 'children']),
|
|
49
|
+
columns: Object.freeze(['columns']),
|
|
50
|
+
tabs: Object.freeze(['tabs']),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const INPUT = new Set(INPUT_WIDGETS);
|
|
54
|
+
const DISPLAY = new Set(DISPLAY_WIDGETS);
|
|
55
|
+
const LAYOUT = new Set(LAYOUT_WIDGETS);
|
|
56
|
+
|
|
57
|
+
/** The answer-schema types each input widget can fill. */
|
|
58
|
+
const PAIRING = Object.freeze({
|
|
59
|
+
text: ['string'], textarea: ['string'], date: ['string'],
|
|
60
|
+
number: ['number', 'integer'], slider: ['number', 'integer'],
|
|
61
|
+
toggle: ['boolean'],
|
|
62
|
+
select: ['string', 'number', 'integer'],
|
|
63
|
+
multiselect: ['array'], rank: ['array'], 'review-list': ['array'],
|
|
64
|
+
'table-select': ['string', 'array'], gallery: ['string', 'array'],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const isKnownWidget = (name) => INPUT.has(name) || DISPLAY.has(name) || LAYOUT.has(name);
|
|
68
|
+
|
|
69
|
+
/** 'input' | 'display' | 'layout' | null. `gallery` is the one name in two classes:
|
|
70
|
+
* with a `field` it collects a pick, without one it only shows. */
|
|
71
|
+
export function widgetClass(name, item) {
|
|
72
|
+
if (name === 'gallery') return item && typeof item.field === 'string' ? 'input' : 'display';
|
|
73
|
+
if (INPUT.has(name)) return 'input';
|
|
74
|
+
if (DISPLAY.has(name)) return 'display';
|
|
75
|
+
if (LAYOUT.has(name)) return 'layout';
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Can this input widget fill a field of this answer schema? */
|
|
80
|
+
export function widgetAcceptsType(name, schema) {
|
|
81
|
+
const types = Object.hasOwn(PAIRING, name) ? PAIRING[name] : null;
|
|
82
|
+
if (!types || !schema || typeof schema !== 'object') return false;
|
|
83
|
+
if (!types.includes(schema.type)) return false;
|
|
84
|
+
if (name === 'date') return schema.format === 'date';
|
|
85
|
+
if (name === 'review-list') {
|
|
86
|
+
const p = schema.items && schema.items.type === 'object' && schema.items.properties;
|
|
87
|
+
return Boolean(p && p.id && p.id.type === 'string' && p.verdict && Array.isArray(p.verdict.enum) && p.verdict.enum.length > 0);
|
|
88
|
+
}
|
|
89
|
+
if (name === 'multiselect' || name === 'rank') return Boolean(schema.items && schema.items.type === 'string');
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// src/shared/forms/form-def.mjs
|
|
2
|
+
// Gate 1 (spec §5): is a declared form well-formed? Run by the agent registry (skip +
|
|
3
|
+
// report), the plugin validator, the agent store (422) and the Agents view (live hints).
|
|
4
|
+
// The last two checks are executable: the form's own `example` must pass its data
|
|
5
|
+
// schema, and the auto answer built from it must pass gate 3 — a declaration proves itself.
|
|
6
|
+
import { ASK_LIMITS, COMMON_ITEM_KEYS, LAYOUT_ITEM_KEYS, isKnownWidget, widgetClass, widgetAcceptsType } from './catalog.mjs';
|
|
7
|
+
import { isValidPath, schemaAtPath } from './paths.mjs';
|
|
8
|
+
import { checkDialect, validate, resolveAnswerSchema } from './schema.mjs';
|
|
9
|
+
import { walkLayout } from './layout.mjs';
|
|
10
|
+
import { autoAnswer, collectAnswer } from './answer.mjs';
|
|
11
|
+
|
|
12
|
+
export const FORM_ID_RE = /^[a-z][a-z0-9-]{0,47}$/;
|
|
13
|
+
export const FORM_SURFACES = Object.freeze(['any', 'web']);
|
|
14
|
+
const MAX_TITLE = 120;
|
|
15
|
+
|
|
16
|
+
const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
17
|
+
const isScalar = (v) => ['string', 'number', 'boolean'].includes(typeof v);
|
|
18
|
+
const err = (path, code, message) => ({ path, code, message });
|
|
19
|
+
|
|
20
|
+
/** Layout keys that hold a data path, per widget. */
|
|
21
|
+
const BINDS = Object.freeze({ compare: ['before', 'after'] });
|
|
22
|
+
const bindKeys = (item) => BINDS[item.widget] || (item.bind !== undefined ? ['bind'] : []);
|
|
23
|
+
/** Widgets that cannot render without a data path. */
|
|
24
|
+
const NEEDS_BIND = new Set(['markdown', 'image', 'gallery', 'pdf', 'code', 'diff', 'table', 'json', 'file-list', 'media',
|
|
25
|
+
'rank', 'table-select', 'review-list']);
|
|
26
|
+
/** Widgets that draw ROWS: their `bind` lands on a list of objects. (`table` is not here: spec
|
|
27
|
+
* §6.2 lets the text display widgets bind a `file` instead.) */
|
|
28
|
+
const ROW_WIDGETS = new Set(['rank', 'table-select', 'review-list', 'gallery', 'file-list']);
|
|
29
|
+
/** Widgets that show ONE file. Only a `file` value is snapshotted at ask time (spec §7), so a
|
|
30
|
+
* path that lands on anything else leaves them nothing to show. */
|
|
31
|
+
const FILE_WIDGETS = new Set(['image', 'pdf', 'media', 'compare']);
|
|
32
|
+
|
|
33
|
+
/** What a layout key may HOLD. catalog.mjs says which keys exist; this says what is in them, so
|
|
34
|
+
* neither the renderer nor the text projection ever meets a shape it cannot read. */
|
|
35
|
+
const TEXT_KEYS = new Set(['label', 'help', 'placeholder', 'unit', 'minLabel', 'maxLabel', 'titleKey', 'bodyKey', 'metaKey',
|
|
36
|
+
'captionKey', 'fileKey', 'noteKey', 'notePlaceholder', 'text', 'title', 'tone', 'caption', 'beforeLabel', 'afterLabel',
|
|
37
|
+
'name', 'lang', 'style']);
|
|
38
|
+
const MAP_KEYS = new Set(['labels', 'descriptions', 'tones']);
|
|
39
|
+
const OPTION_KEYS = Object.freeze(['from', 'value', 'label', 'description']);
|
|
40
|
+
const STYLES = Object.freeze({ select: ['cards', 'segmented', 'dropdown'], multiselect: ['rows', 'chips'] });
|
|
41
|
+
const isText = (v) => typeof v === 'string';
|
|
42
|
+
const optText = (v) => v === undefined || isText(v);
|
|
43
|
+
|
|
44
|
+
/** → string[]: one message per key of `item` that holds the wrong kind of value. */
|
|
45
|
+
function valueErrors(item) {
|
|
46
|
+
const out = [];
|
|
47
|
+
const w = item.widget;
|
|
48
|
+
for (const [k, v] of Object.entries(item)) {
|
|
49
|
+
if (TEXT_KEYS.has(k) && !isText(v)) out.push(`"${k}" must be text`);
|
|
50
|
+
if (MAP_KEYS.has(k) && !(isObject(v) && Object.values(v).every(isText))) out.push(`"${k}" is { value: text }`);
|
|
51
|
+
}
|
|
52
|
+
if (isText(item.style) && Object.hasOwn(STYLES, w) && !STYLES[w].includes(item.style)) out.push(`"style" of "${w}" is one of: ${STYLES[w].join(', ')}`);
|
|
53
|
+
if (item.mono !== undefined && typeof item.mono !== 'boolean') out.push('"mono" must be true or false');
|
|
54
|
+
if (item.rows !== undefined && !(Number.isInteger(item.rows) && item.rows >= 1)) out.push('"rows" must be a whole number, 1 or more');
|
|
55
|
+
if (item.options !== undefined) {
|
|
56
|
+
if (!isObject(item.options)) out.push('"options" is { from, value, label, description }');
|
|
57
|
+
else for (const k of Object.keys(item.options)) {
|
|
58
|
+
if (!OPTION_KEYS.includes(k)) out.push(`"options" has no "${k}" key`);
|
|
59
|
+
else if (!isText(item.options[k])) out.push(`"options.${k}" must be text`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (item.requires !== undefined && !(isObject(item.requires) && (item.requires.askCatalog === undefined
|
|
63
|
+
|| (Number.isInteger(item.requires.askCatalog) && item.requires.askCatalog >= 1)))) out.push('"requires" is { askCatalog: n }');
|
|
64
|
+
if (item.fallback !== undefined && !isObject(item.fallback)) out.push('"fallback" is a layout item');
|
|
65
|
+
if (w === 'group' && !Array.isArray(item.children)) out.push('"group" needs "children": a list of items');
|
|
66
|
+
if (w === 'columns' && !(Array.isArray(item.columns) && item.columns.length > 0 && item.columns.every(Array.isArray))) out.push('"columns" is a non-empty list of item lists');
|
|
67
|
+
if (w === 'tabs' && !(Array.isArray(item.tabs) && item.tabs.length > 0
|
|
68
|
+
&& item.tabs.every((t) => isObject(t) && isText(t.label) && Array.isArray(t.children)))) out.push('"tabs" is a non-empty list of { label, children }');
|
|
69
|
+
// a column may carry more (align, mono, format: the renderer's business) — only what P1 reads is held
|
|
70
|
+
if ((w === 'table' || w === 'table-select') && item.columns !== undefined && !(Array.isArray(item.columns) && item.columns.length > 0
|
|
71
|
+
&& item.columns.every((c) => isObject(c) && isText(c.key) && optText(c.label) && optText(c.unit)))) out.push('"columns" is a non-empty list of { key, label }');
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Why `schema` (what a row widget's `bind` landed on) cannot feed it, or null. Below an opaque
|
|
76
|
+
* object there is no schema to hold it to, so it is let through. */
|
|
77
|
+
function rowBindError(widget, schema, needsId) {
|
|
78
|
+
if (schema.type === undefined) return null;
|
|
79
|
+
if (schema.type !== 'array' || !isObject(schema.items) || schema.items.type !== 'object') return `"${widget}" needs a list of objects`;
|
|
80
|
+
const p = schema.items.properties;
|
|
81
|
+
// required, not only declared: a row the agent may leave without an id cannot be ranked, picked or judged
|
|
82
|
+
const required = Array.isArray(schema.items.required) ? schema.items.required : [];
|
|
83
|
+
if (needsId && isObject(p) && !(Object.hasOwn(p, 'id') && p.id.type === 'string' && required.includes('id'))) return `rows of "${widget}" need a required text "id"`;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What a scalar schema type is to a reader; two types of one kind can stand in for each other. */
|
|
88
|
+
const KIND = Object.freeze({ string: 'text', file: 'text', number: 'a number', integer: 'a number', boolean: 'on/off' });
|
|
89
|
+
|
|
90
|
+
/** Why the data `path` lands on cannot feed `enumFrom` / `defaultFrom` of `node`, or null.
|
|
91
|
+
* `enumFrom` wants values — one, a list of them, or a column; `defaultFrom` wants one value for
|
|
92
|
+
* a scalar field and a list for a list field. Below an opaque object (C3) nothing is held. */
|
|
93
|
+
function fromError(k, node, path, landed) {
|
|
94
|
+
if (landed.type === undefined) return null;
|
|
95
|
+
const many = path.includes('[]') || landed.type === 'array';
|
|
96
|
+
if (node.type === 'array') return many ? null : `${path} is one value, and this field is a list`;
|
|
97
|
+
if (k === 'defaultFrom' && many) return `${path} is a list, and this field takes one value`;
|
|
98
|
+
const leaf = landed.type === 'array' ? landed.items : landed;
|
|
99
|
+
if (!isObject(leaf) || leaf.type === undefined) return null;
|
|
100
|
+
if (!Object.hasOwn(KIND, leaf.type)) return `${path} holds ${leaf.type === 'object' ? 'objects' : 'lists'}, not values`;
|
|
101
|
+
if (Object.hasOwn(KIND, node.type) && KIND[leaf.type] !== KIND[node.type]) return `${path} is ${KIND[leaf.type]}, and this field is ${KIND[node.type]}`;
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Why a `when` on the answer property `schema` could never hold for `want`, or null. Equality is
|
|
106
|
+
* all `when` has: a list or an object never equals anything, a value of another type never
|
|
107
|
+
* matches, and one outside a closed `enum` is never chosen. (`enumFrom` is open until ask time.) */
|
|
108
|
+
function whenError(f, schema, want) {
|
|
109
|
+
if (!Object.hasOwn(KIND, schema.type)) return `"when" cannot compare "${f}": it is ${schema.type === 'array' ? 'a list' : 'an object'}`;
|
|
110
|
+
const fits = (v) => (schema.type === 'integer' ? Number.isInteger(v) : typeof v === (schema.type === 'number' ? 'number' : schema.type));
|
|
111
|
+
const never = (Array.isArray(want) ? want : [want]).find((v) => !fits(v) || (Array.isArray(schema.enum) && !schema.enum.includes(v)));
|
|
112
|
+
return never === undefined ? null : `"when" waits for ${f} = ${JSON.stringify(never)}, which "${f}" can never be`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The first circle in `deps` (field → the fields its visibility waits on) as [a, b, …, a], or null. */
|
|
116
|
+
function whenCycle(deps) {
|
|
117
|
+
const done = new Set();
|
|
118
|
+
const trail = [];
|
|
119
|
+
const visit = (f) => {
|
|
120
|
+
const at = trail.indexOf(f);
|
|
121
|
+
if (at >= 0) return [...trail.slice(at), f];
|
|
122
|
+
if (done.has(f) || !deps.has(f)) return null;
|
|
123
|
+
trail.push(f);
|
|
124
|
+
for (const next of deps.get(f)) { const found = visit(next); if (found) return found; }
|
|
125
|
+
trail.pop();
|
|
126
|
+
done.add(f);
|
|
127
|
+
return null;
|
|
128
|
+
};
|
|
129
|
+
for (const f of deps.keys()) { const found = visit(f); if (found) return found; }
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** → { ok, errors: [{ path, code, message }] } */
|
|
134
|
+
export function validateFormDef(def, { id } = {}) {
|
|
135
|
+
const errors = [];
|
|
136
|
+
if (id !== undefined && !FORM_ID_RE.test(String(id))) errors.push(err('', 'bad-id', `form id "${id}" must match ${FORM_ID_RE}`));
|
|
137
|
+
if (!isObject(def)) return { ok: false, errors: [...errors, err('', 'dialect', 'a form is an object')] };
|
|
138
|
+
if (!Number.isInteger(def.version) || def.version < 1) errors.push(err('version', 'dialect', '"version" is a positive integer'));
|
|
139
|
+
if (typeof def.title !== 'string' || def.title.trim() === '' || def.title.length > MAX_TITLE) errors.push(err('title', 'dialect', `"title" is 1–${MAX_TITLE} characters`));
|
|
140
|
+
if (def.surface !== undefined && !FORM_SURFACES.includes(def.surface)) errors.push(err('surface', 'dialect', '"surface" is "any" or "web"'));
|
|
141
|
+
|
|
142
|
+
for (const side of ['data', 'answer']) {
|
|
143
|
+
if (!isObject(def[side]) || def[side].type !== 'object') { errors.push(err(side, 'dialect', `"${side}" is an object schema`)); continue; }
|
|
144
|
+
for (const e of checkDialect(def[side], { side })) errors.push(err(e.path ? `${side}.${e.path}` : side, e.code, e.message));
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(def.layout) || def.layout.length === 0) errors.push(err('layout', 'dialect', '"layout" is a non-empty list'));
|
|
147
|
+
if (errors.length) return { ok: false, errors };
|
|
148
|
+
|
|
149
|
+
const props = def.answer.properties || {};
|
|
150
|
+
// enumFrom / defaultFrom must point into the declared data
|
|
151
|
+
(function paths(node, path) {
|
|
152
|
+
if (!isObject(node)) return;
|
|
153
|
+
for (const k of ['enumFrom', 'defaultFrom']) {
|
|
154
|
+
if (node[k] === undefined) continue;
|
|
155
|
+
const landed = schemaAtPath(node[k], def.data);
|
|
156
|
+
const why = landed ? fromError(k, node, node[k], landed) : null;
|
|
157
|
+
if (!landed) errors.push(err(path, 'bad-bind', `"${k}": ${node[k]} is not in the data schema`));
|
|
158
|
+
else if (why) errors.push(err(path, 'bad-bind', `"${k}": ${why}`));
|
|
159
|
+
}
|
|
160
|
+
if (node.items) paths(node.items, `${path}[]`);
|
|
161
|
+
if (isObject(node.properties)) for (const [k, v] of Object.entries(node.properties)) paths(v, `${path}.${k}`);
|
|
162
|
+
})(def.answer, 'answer');
|
|
163
|
+
|
|
164
|
+
const fields = new Set();
|
|
165
|
+
const whens = []; // [{ at, field }] every field a `when` names, the item's own or an ancestor's
|
|
166
|
+
const deps = new Map(); // input field → the fields its visibility waits on
|
|
167
|
+
const whereOf = new Map(); // input field → its item's address
|
|
168
|
+
let n = 0;
|
|
169
|
+
walkLayout(def.layout, (raw, eff, parents) => {
|
|
170
|
+
const at = `layout#${n += 1}`;
|
|
171
|
+
if (!isObject(raw) || typeof raw.widget !== 'string') { errors.push(err(at, 'dialect', 'a layout item is an object with a "widget"')); return; }
|
|
172
|
+
if (!eff) { errors.push(err(at, 'unknown-widget', `"${raw.widget}" is not in the catalog and has no usable fallback`)); return; }
|
|
173
|
+
if (!isKnownWidget(eff.widget)) return;
|
|
174
|
+
for (const k of Object.keys(eff)) {
|
|
175
|
+
if (!COMMON_ITEM_KEYS.includes(k) && !LAYOUT_ITEM_KEYS[eff.widget].includes(k)) errors.push(err(at, 'dialect', `"${eff.widget}" has no "${k}" key`));
|
|
176
|
+
}
|
|
177
|
+
for (const message of valueErrors(eff)) errors.push(err(at, 'dialect', message));
|
|
178
|
+
const cls = widgetClass(eff.widget, eff);
|
|
179
|
+
if (cls === 'input') {
|
|
180
|
+
const s = typeof eff.field === 'string' && Object.hasOwn(props, eff.field) ? props[eff.field] : null;
|
|
181
|
+
if (typeof eff.field !== 'string') errors.push(err(at, 'unknown-field', `"${eff.widget}" needs a "field"`));
|
|
182
|
+
else if (!s) errors.push(err(at, 'unknown-field', `"${eff.field}" is not an answer property`));
|
|
183
|
+
else if (!widgetAcceptsType(eff.widget, s)) errors.push(err(at, 'bad-pairing', `"${eff.widget}" cannot fill "${eff.field}" (${s.type})`));
|
|
184
|
+
if (typeof eff.field === 'string') {
|
|
185
|
+
if (fields.has(eff.field)) errors.push(err(at, 'dup-field', `"${eff.field}" is bound by more than one item`));
|
|
186
|
+
else {
|
|
187
|
+
fields.add(eff.field);
|
|
188
|
+
whereOf.set(eff.field, at);
|
|
189
|
+
// an ancestor's `when` hides this field too
|
|
190
|
+
deps.set(eff.field, new Set([eff, ...parents].flatMap((p) => (isObject(p) && isObject(p.when) ? Object.keys(p.when) : []))));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} else if (eff.field !== undefined) errors.push(err(at, 'unknown-field', `"${eff.widget}" does not collect a value`));
|
|
194
|
+
if (NEEDS_BIND.has(eff.widget) && eff.bind === undefined) errors.push(err(at, 'bad-bind', `"${eff.widget}" needs "bind"`));
|
|
195
|
+
for (const k of bindKeys(eff)) {
|
|
196
|
+
const landed = isValidPath(eff[k]) ? schemaAtPath(eff[k], def.data) : null;
|
|
197
|
+
if (!landed) { errors.push(err(at, 'bad-bind', `"${k}": ${JSON.stringify(eff[k])} is not in the data schema`)); continue; }
|
|
198
|
+
const why = k === 'bind' && ROW_WIDGETS.has(eff.widget) ? rowBindError(eff.widget, landed, cls === 'input') : null;
|
|
199
|
+
if (why) errors.push(err(at, 'bad-bind', `"bind": ${why}`));
|
|
200
|
+
// a column resolves to a LIST whatever it holds, and these widgets show one file
|
|
201
|
+
if (FILE_WIDGETS.has(eff.widget) && eff[k].includes('[]')) {
|
|
202
|
+
errors.push(err(at, 'bad-bind', `"${k}": "${eff.widget}" shows one file, and ${eff[k]} is a column of them`));
|
|
203
|
+
// `landed.type` is undefined below an opaque object (C3): nothing to hold it to
|
|
204
|
+
} else if (FILE_WIDGETS.has(eff.widget) && landed.type !== undefined && landed.type !== 'file') {
|
|
205
|
+
errors.push(err(at, 'bad-bind', `"${k}": "${eff.widget}" shows a file, and ${eff[k]} is ${landed.type === 'string' ? 'a string' : `of type ${landed.type}`}`));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (eff.widget === 'compare') for (const k of ['before', 'after']) if (eff[k] === undefined) errors.push(err(at, 'bad-bind', `"compare" needs "${k}"`));
|
|
209
|
+
if (isObject(eff.options)) {
|
|
210
|
+
const landed = isValidPath(eff.options.from) ? schemaAtPath(eff.options.from, def.data) : null;
|
|
211
|
+
const why = landed ? rowBindError(eff.widget, landed, false) : null;
|
|
212
|
+
if (!landed) errors.push(err(at, 'bad-bind', '"options.from" is not in the data schema'));
|
|
213
|
+
else if (why) errors.push(err(at, 'bad-bind', `"options.from": ${why}`));
|
|
214
|
+
}
|
|
215
|
+
if (eff.suggest !== undefined && (!Array.isArray(eff.suggest) || !eff.suggest.every((x) => typeof x === 'string'))) errors.push(err(at, 'dialect', '"suggest" is a list of strings'));
|
|
216
|
+
if (eff.when !== undefined) {
|
|
217
|
+
if (!isObject(eff.when) || Object.keys(eff.when).length === 0) errors.push(err(at, 'dialect', '"when" is { field: value }'));
|
|
218
|
+
else for (const [f, want] of Object.entries(eff.when)) {
|
|
219
|
+
const shaped = isScalar(want) || (Array.isArray(want) && want.length > 0 && want.every(isScalar));
|
|
220
|
+
if (!Object.hasOwn(props, f)) errors.push(err(at, 'unknown-field', `"when" names "${f}", which is not an answer property`));
|
|
221
|
+
else {
|
|
222
|
+
whens.push({ at, field: f });
|
|
223
|
+
// a condition nobody can meet hides its item for good
|
|
224
|
+
const never = shaped ? whenError(f, props[f], want) : null;
|
|
225
|
+
if (never) errors.push(err(at, 'dialect', never));
|
|
226
|
+
}
|
|
227
|
+
if (f === eff.field) errors.push(err(at, 'dialect', 'an item cannot depend on its own field'));
|
|
228
|
+
if (!shaped) errors.push(err(at, 'dialect', '"when" compares against a scalar or a list of scalars'));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
// A `when` must be answerable. A field no input collects never gets a value, so the item could
|
|
233
|
+
// never be shown; and fields that wait on each other are all hidden from a human — whose EMPTY
|
|
234
|
+
// answer would then pass gate 3, while auto mode (which starts from every default) answers them.
|
|
235
|
+
// Held only on a layout that is otherwise sound: an item whose `field` is wrong already says why
|
|
236
|
+
// the field it meant is not collected, and must not be echoed by every `when` that names it.
|
|
237
|
+
if (errors.length === 0) {
|
|
238
|
+
for (const w of whens) if (!fields.has(w.field)) errors.push(err(w.at, 'unknown-field', `"when" names "${w.field}", which no item collects`));
|
|
239
|
+
const circle = whenCycle(deps);
|
|
240
|
+
if (circle) errors.push(err(whereOf.get(circle[0]), 'dialect', `"when" is circular: ${circle.join(' → ')}`));
|
|
241
|
+
}
|
|
242
|
+
for (const f of def.answer.required || []) if (!fields.has(f)) errors.push(err(`answer.${f}`, 'unreachable', `required field "${f}" has no input in the layout`));
|
|
243
|
+
if (errors.length) return { ok: false, errors };
|
|
244
|
+
|
|
245
|
+
if (!isObject(def.example)) return { ok: false, errors: [err('example', 'bad-example', 'a form ships an "example" of its data')] };
|
|
246
|
+
const ex = validate(def.data, def.example);
|
|
247
|
+
if (!ex.ok) return { ok: false, errors: ex.errors.map((e) => err(`example.${e.path}`, 'bad-example', e.message)) };
|
|
248
|
+
const auto = collectAnswer(def, resolveAnswerSchema(def.answer, def.example), autoAnswer(def, def.example));
|
|
249
|
+
for (const e of auto.errors) errors.push(err(`answer.${e.path}`, 'bad-auto', `unattended runs cannot answer "${e.path}": ${e.message} Give it a "default" or "defaultFrom".`));
|
|
250
|
+
return { ok: errors.length === 0, errors };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One line, no trailing period: it is interpolated into `BAD_ASK_FORM: <agent>/<form>: <reason>`. */
|
|
254
|
+
const reasonOf = (errors) => errors.slice(0, 3)
|
|
255
|
+
.map((e) => (e.path ? `${e.path}: ${e.message}` : e.message).replace(/\s+/g, ' ').replace(/\.$/, '')).join('; ');
|
|
256
|
+
|
|
257
|
+
/** A byte limit does not bound the stack: 64 KB holds 30 000 levels of `[`, and Node 22 overflows
|
|
258
|
+
* near 5 000 of them inside JSON.stringify, which is the size check itself. So depth is counted
|
|
259
|
+
* first, and a block past this is refused by name. No written form comes near: the fixtures are 10. */
|
|
260
|
+
const MAX_JSON_DEPTH = 256;
|
|
261
|
+
|
|
262
|
+
/** Is `v` nested deeper than `max`? A work list, never recursion: this is what guards the stack. */
|
|
263
|
+
function nestsDeeperThan(v, max) {
|
|
264
|
+
const todo = [[v, 1]];
|
|
265
|
+
while (todo.length) {
|
|
266
|
+
const [cur, depth] = todo.pop();
|
|
267
|
+
if (cur === null || typeof cur !== 'object') continue;
|
|
268
|
+
if (depth > max) return true;
|
|
269
|
+
for (const child of Object.values(cur)) todo.push([child, depth + 1]);
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The sidecar's `ask` block → the forms that passed gate 1 plus the ones that did not.
|
|
275
|
+
* Never throws: a broken block costs the agent its forms, never the agent. */
|
|
276
|
+
export function normalizeAskBlock(raw) {
|
|
277
|
+
if (raw === undefined || raw === null) return { forms: {}, dropped: [] };
|
|
278
|
+
if (!isObject(raw) || !isObject(raw.forms)) return { forms: {}, dropped: [{ id: '*', reason: '"ask" is { forms: { <id>: <form> } }' }] };
|
|
279
|
+
if (nestsDeeperThan(raw, MAX_JSON_DEPTH)) return { forms: {}, dropped: [{ id: '*', reason: `"ask" is nested deeper than ${MAX_JSON_DEPTH} levels` }] };
|
|
280
|
+
// bytes, not UTF-16 units: a block of non-Latin text is up to three times its `.length`
|
|
281
|
+
if (new TextEncoder().encode(JSON.stringify(raw)).length > ASK_LIMITS.askBlockBytes) return { forms: {}, dropped: [{ id: '*', reason: `"ask" is larger than ${ASK_LIMITS.askBlockBytes} bytes` }] };
|
|
282
|
+
const forms = {};
|
|
283
|
+
const dropped = [];
|
|
284
|
+
for (const [id, def] of Object.entries(raw.forms)) {
|
|
285
|
+
if (Object.keys(forms).length >= ASK_LIMITS.formsPerAgent) { dropped.push({ id, reason: `more than ${ASK_LIMITS.formsPerAgent} forms` }); continue; }
|
|
286
|
+
const { ok, errors } = validateFormDef(def, { id });
|
|
287
|
+
if (ok) forms[id] = def; else dropped.push({ id, reason: reasonOf(errors) });
|
|
288
|
+
}
|
|
289
|
+
return { forms, dropped };
|
|
290
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/shared/forms/layout.mjs
|
|
2
|
+
// Walking a form layout (spec §3, §10): children of the three layout widgets, `when`
|
|
3
|
+
// visibility, and the `requires` / `fallback` downgrade. Every consumer — gate 1, the
|
|
4
|
+
// renderer, the projection, answer collection — walks through here so they agree on
|
|
5
|
+
// which items exist and which fields are live.
|
|
6
|
+
import { ASK_CATALOG_VERSION, isKnownWidget } from './catalog.mjs';
|
|
7
|
+
|
|
8
|
+
const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
9
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
10
|
+
|
|
11
|
+
/** The item that actually renders on a host with this catalog: the item itself, its
|
|
12
|
+
* `fallback` (recursively), or null when neither is renderable. */
|
|
13
|
+
export function effectiveItem(item, catalogVersion = ASK_CATALOG_VERSION) {
|
|
14
|
+
for (let cur = item, hops = 0; isObject(cur) && hops < 4; cur = cur.fallback, hops += 1) {
|
|
15
|
+
const needs = isObject(cur.requires) && Number.isInteger(cur.requires.askCatalog) ? cur.requires.askCatalog : 1;
|
|
16
|
+
if (isKnownWidget(cur.widget) && needs <= catalogVersion) return cur;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Child item arrays of a layout widget; [] for everything else. */
|
|
22
|
+
export function childrenOf(item) {
|
|
23
|
+
if (!isObject(item)) return [];
|
|
24
|
+
if (item.widget === 'group') return [list(item.children)];
|
|
25
|
+
if (item.widget === 'columns') return list(item.columns).map(list);
|
|
26
|
+
if (item.widget === 'tabs') return list(item.tabs).map((t) => list(t && t.children));
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Depth-first. `fn(raw, effective, parents)`; children come from the effective item. */
|
|
31
|
+
export function walkLayout(layout, fn, parents = []) {
|
|
32
|
+
for (const raw of list(layout)) {
|
|
33
|
+
const eff = effectiveItem(raw);
|
|
34
|
+
fn(raw, eff, parents);
|
|
35
|
+
for (const kids of childrenOf(eff || raw)) walkLayout(kids, fn, [...parents, eff || raw]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `{ field: value }` or `{ field: [v1, v2] }`, equality only, all keys must hold. */
|
|
40
|
+
export function whenOk(when, values) {
|
|
41
|
+
if (!isObject(when)) return true;
|
|
42
|
+
// own values only: an inherited `constructor` is not something the human answered
|
|
43
|
+
const got = (field) => (isObject(values) && Object.hasOwn(values, field) ? values[field] : undefined);
|
|
44
|
+
return Object.entries(when).every(([field, want]) => (
|
|
45
|
+
Array.isArray(want) ? want.includes(got(field)) : got(field) === want));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Answer fields whose item — and every ancestor — passes its `when`. */
|
|
49
|
+
export function visibleFields(layout, values) {
|
|
50
|
+
const out = [];
|
|
51
|
+
(function visit(items) {
|
|
52
|
+
for (const raw of list(items)) {
|
|
53
|
+
const eff = effectiveItem(raw);
|
|
54
|
+
if (!eff || !whenOk(eff.when, values)) continue;
|
|
55
|
+
if (typeof eff.field === 'string') out.push(eff.field);
|
|
56
|
+
for (const kids of childrenOf(eff)) visit(kids);
|
|
57
|
+
}
|
|
58
|
+
})(layout);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Unique effective widget names, in layout order. */
|
|
63
|
+
export function widgetsUsed(layout) {
|
|
64
|
+
const seen = [];
|
|
65
|
+
walkLayout(layout, (raw, eff) => { if (eff && !seen.includes(eff.widget)) seen.push(eff.widget); });
|
|
66
|
+
return seen;
|
|
67
|
+
}
|