@worca/app 1.2.0 → 1.3.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 +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3715 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1359 -214
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +423 -25
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1450 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1282 -193
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// ui/public/memory-view.mjs
|
|
2
|
+
// Pure DOM renderers for the Memory view (agent-memory-design.md §10). Every function takes the
|
|
3
|
+
// target `document` via opts (defaults to the browser global) and returns DETACHED elements — no
|
|
4
|
+
// fetch, no listeners outside the returned tree (guardrails-view.mjs posture). app.js owns the
|
|
5
|
+
// endpoint calls and the mounting; node:test drives these via jsdom. Interactive elements carry a
|
|
6
|
+
// routing class (mem-new, mem-save, mem-cancel, mem-delete, mem-restore, mem-defrag) plus
|
|
7
|
+
// data-name / data-id, so the controller wires ONE delegated listener per host. The namespace is
|
|
8
|
+
// `mem-*`: `mv-*` belongs to Settings → Models (models-view.mjs). Every string that came from disk
|
|
9
|
+
// (names, hooks, bodies, reasons) is a textContent / value — never innerHTML.
|
|
10
|
+
|
|
11
|
+
function h(doc, tag, cls, text) {
|
|
12
|
+
const n = doc.createElement(tag);
|
|
13
|
+
if (cls) n.className = cls;
|
|
14
|
+
if (text != null) n.textContent = text;
|
|
15
|
+
return n;
|
|
16
|
+
}
|
|
17
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
18
|
+
|
|
19
|
+
/** The ONE name rule, shared by the editor's pre-check and the server's 400 text, so a user never
|
|
20
|
+
* sees two spellings of the same rule. The server (isValidMemoryName) stays the authority — this
|
|
21
|
+
* check is instant feedback, not a complete gate (it knows nothing about Win32 reserved stems). */
|
|
22
|
+
export const MEMORY_NAME_HELP = 'letters, digits, ".", "_" and "-" only, no extension, no leading or trailing dot';
|
|
23
|
+
|
|
24
|
+
/** The hash for a scope (and optionally one file): global lives under Settings, a project under its project page's Memory tab. */
|
|
25
|
+
export function memoryRoute(scopeKey, name = '') {
|
|
26
|
+
const base = scopeKey === 'global' ? 'settings/memory' : `${scopeKey}/memory`;
|
|
27
|
+
return name ? `${base}/${encodeURIComponent(name)}` : base;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const BADGES = { fresh: ['No memory yet', ''], ok: ['Healthy', 'green'], due: ['Defragment due', 'amber'], overdue: ['Defragment overdue', 'red'] };
|
|
31
|
+
export function healthBadge(level) {
|
|
32
|
+
const [text, cls] = BADGES[level] || BADGES.fresh;
|
|
33
|
+
return { text, cls };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `YYYY-MM-DD HH:MM` in LOCAL time (like fmtDate everywhere else in the app), or '' for anything
|
|
37
|
+
* unparsable — these dates are display-only. */
|
|
38
|
+
export function formatWhen(iso) {
|
|
39
|
+
const t = Date.parse(iso || '');
|
|
40
|
+
if (!Number.isFinite(t)) return '';
|
|
41
|
+
const d = new Date(t);
|
|
42
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
43
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const NO_HOST_HINT = 'Register a project on the Projects page to host the global defragment run.';
|
|
47
|
+
|
|
48
|
+
/** The defragment glyph: three rows of blocks, the lower ones fragmented. Pure SVG elements (no
|
|
49
|
+
* text node), so the button's textContent stays its label alone. */
|
|
50
|
+
function defragIcon(doc) {
|
|
51
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
52
|
+
const svg = doc.createElementNS(NS, 'svg');
|
|
53
|
+
svg.setAttribute('width', '13'); svg.setAttribute('height', '13'); svg.setAttribute('viewBox', '0 0 24 24');
|
|
54
|
+
svg.setAttribute('fill', 'currentColor'); svg.setAttribute('aria-hidden', 'true');
|
|
55
|
+
for (const [x, y, w] of [[3, 4, 18], [3, 10, 8], [13, 10, 8], [3, 16, 5], [10, 16, 11]]) {
|
|
56
|
+
const r = doc.createElementNS(NS, 'rect');
|
|
57
|
+
r.setAttribute('x', String(x)); r.setAttribute('y', String(y)); r.setAttribute('width', String(w)); r.setAttribute('height', '4'); r.setAttribute('rx', '1');
|
|
58
|
+
svg.appendChild(r);
|
|
59
|
+
}
|
|
60
|
+
return svg;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The health card: badge + reasons + counters + the ONE Defragment control (spec §10). `host` is
|
|
65
|
+
* `{ key, name } | null` — the project that would host a GLOBAL defragment run (a project scope
|
|
66
|
+
* hosts its own, so `host` is ignored there); with no registered project the button is disabled
|
|
67
|
+
* and the hint says so, in visible text as well as the title. While a run is live the button stays
|
|
68
|
+
* ENABLED, reads "Defragmenting… open the run" and carries `data-run-id`: the controller routes to
|
|
69
|
+
* `#running/<id>`.
|
|
70
|
+
*/
|
|
71
|
+
export function renderHealthCard(report, { doc = globalThis.document, host = null } = {}) {
|
|
72
|
+
const health = report?.health || {};
|
|
73
|
+
const isGlobal = String(report?.scope || 'global') === 'global';
|
|
74
|
+
const card = h(doc, 'div', 'card mem-health');
|
|
75
|
+
const head = h(doc, 'div', 'mem-health-head');
|
|
76
|
+
const { text, cls } = healthBadge(health.level);
|
|
77
|
+
head.appendChild(h(doc, 'span', `badge${cls ? ` ${cls}` : ''}`, text));
|
|
78
|
+
head.appendChild(h(doc, 'span', 'mem-counters',
|
|
79
|
+
`${plural(health.files || 0, 'file')} · ${health.bytes || 0} bytes · ${health.alwaysOnBytes || 0} bytes always loaded · ${plural(health.writesSinceDefrag || 0, 'write')} since the last defragment` +
|
|
80
|
+
(health.lastDefragAt ? ` · last defragmented ${formatWhen(health.lastDefragAt)}` : '')));
|
|
81
|
+
// The ONE control sits at the head's right edge (margin-left:auto), styled like every other
|
|
82
|
+
// secondary pill (btn-ghost) with the defragment glyph before its label.
|
|
83
|
+
const runId = report?.defragRunId ? String(report.defragRunId) : '';
|
|
84
|
+
const btn = h(doc, 'button', 'btn btn-ghost btn-mini mem-defrag');
|
|
85
|
+
btn.type = 'button';
|
|
86
|
+
btn.appendChild(defragIcon(doc));
|
|
87
|
+
btn.appendChild(doc.createTextNode(runId ? 'Defragmenting… open the run' : 'Defragment'));
|
|
88
|
+
if (runId) btn.dataset.runId = runId;
|
|
89
|
+
else if (isGlobal && !host) { btn.disabled = true; btn.title = NO_HOST_HINT; }
|
|
90
|
+
head.appendChild(btn);
|
|
91
|
+
card.appendChild(head);
|
|
92
|
+
if (Array.isArray(health.reasons) && health.reasons.length) {
|
|
93
|
+
const ul = h(doc, 'ul', 'mem-reasons');
|
|
94
|
+
for (const r of health.reasons) ul.appendChild(h(doc, 'li', '', String(r)));
|
|
95
|
+
card.appendChild(ul);
|
|
96
|
+
}
|
|
97
|
+
if (isGlobal && !runId) {
|
|
98
|
+
card.appendChild(h(doc, 'small', 'hint mem-host-hint',
|
|
99
|
+
host ? `Runs on ${host.name || host.key} — pick another project on the New pipeline page.` : NO_HOST_HINT));
|
|
100
|
+
}
|
|
101
|
+
card.appendChild(h(doc, 'small', 'hint', 'A defragment run merges duplicate topics, splits overgrown files, drops stale rules and tightens hooks. It is an ordinary pipeline run; the previous files stay in History.'));
|
|
102
|
+
return card;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The file list: a head with the New button, one row per file. The ROW is the control (click,
|
|
106
|
+
* Enter, Space) — no nested button, so `role="button"` holds no interactive descendant. */
|
|
107
|
+
export function renderFileList(files, { doc = globalThis.document, selected = '' } = {}) {
|
|
108
|
+
const wrap = h(doc, 'div', 'mem-list');
|
|
109
|
+
const head = h(doc, 'div', 'mem-list-head');
|
|
110
|
+
head.appendChild(h(doc, 'span', 'mem-list-title', 'Files'));
|
|
111
|
+
const add = h(doc, 'button', 'btn btn-ghost btn-mini mem-new', 'New file');
|
|
112
|
+
add.type = 'button';
|
|
113
|
+
head.appendChild(add);
|
|
114
|
+
wrap.appendChild(head);
|
|
115
|
+
const list = Array.isArray(files) ? files : [];
|
|
116
|
+
if (!list.length) { wrap.appendChild(h(doc, 'div', 'hist-empty', 'No memory files yet — agents and Ask Worca add them, or click New file.')); return wrap; }
|
|
117
|
+
for (const f of list) {
|
|
118
|
+
const row = h(doc, 'div', `mem-row${f.name === selected ? ' on' : ''}`);
|
|
119
|
+
row.dataset.name = f.name;
|
|
120
|
+
row.setAttribute('role', 'button'); row.tabIndex = 0;
|
|
121
|
+
const main = h(doc, 'div', 'mem-row-main');
|
|
122
|
+
main.appendChild(h(doc, 'div', 'mem-row-name mono', `${f.name}.md`));
|
|
123
|
+
main.appendChild(h(doc, 'div', 'mem-row-hook', f.description || '(no description)'));
|
|
124
|
+
const meta = [formatWhen(f.updated), f.source || ''].filter(Boolean).join(' · ') + (f.hasFrontmatter === false ? ' · no frontmatter' : '');
|
|
125
|
+
main.appendChild(h(doc, 'div', 'mem-row-meta', meta));
|
|
126
|
+
row.appendChild(main);
|
|
127
|
+
wrap.appendChild(row);
|
|
128
|
+
}
|
|
129
|
+
return wrap;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The editor: the whole file (frontmatter included) as text. A NEW file has an editable name and
|
|
133
|
+
* no Delete; `locked` (a live defragment run on this scope) disables Save and Delete — Cancel
|
|
134
|
+
* always closes. */
|
|
135
|
+
export function renderEditor(file, { doc = globalThis.document, isNew = false, msg = '', msgErr = false, locked = false } = {}) {
|
|
136
|
+
const wrap = h(doc, 'div', 'mem-editor');
|
|
137
|
+
const nameField = h(doc, 'div', 'field field-compact');
|
|
138
|
+
nameField.appendChild(h(doc, 'label', '', 'Name'));
|
|
139
|
+
const name = h(doc, 'input', 'input mono mem-name');
|
|
140
|
+
name.type = 'text'; name.spellcheck = false; name.value = file?.name || '';
|
|
141
|
+
name.readOnly = !isNew;
|
|
142
|
+
name.placeholder = 'topic-name';
|
|
143
|
+
nameField.appendChild(name);
|
|
144
|
+
if (isNew) nameField.appendChild(h(doc, 'small', 'hint mem-name-help', MEMORY_NAME_HELP));
|
|
145
|
+
wrap.appendChild(nameField);
|
|
146
|
+
const textField = h(doc, 'div', 'field');
|
|
147
|
+
textField.appendChild(h(doc, 'label', '', 'Markdown (frontmatter + body)'));
|
|
148
|
+
const ta = h(doc, 'textarea', 'textarea mono mem-text');
|
|
149
|
+
ta.rows = 16; ta.spellcheck = false;
|
|
150
|
+
ta.value = file?.text || (isNew ? '---\nname: \ndescription: \n---\n' : '');
|
|
151
|
+
textField.appendChild(ta);
|
|
152
|
+
textField.appendChild(h(doc, 'small', 'hint', 'Keep name, description (one line: when the file matters — shown here and to Ask Worca) and optional paths (the file loads only when a matching file is read) in the frontmatter; worca stamps source and updated.'));
|
|
153
|
+
wrap.appendChild(textField);
|
|
154
|
+
const text = msg || (locked ? 'A defragment run is live on this scope — Save and Delete resume when it finishes.' : '');
|
|
155
|
+
const status = h(doc, 'div', `hint mem-msg${msgErr ? ' err' : ''}`, text);
|
|
156
|
+
status.setAttribute('role', 'status');
|
|
157
|
+
wrap.appendChild(status);
|
|
158
|
+
const actions = h(doc, 'div', 'actions mem-actions');
|
|
159
|
+
if (!isNew) {
|
|
160
|
+
const d = h(doc, 'button', 'btn btn-danger btn-mini mem-delete', 'Delete');
|
|
161
|
+
d.type = 'button'; d.disabled = !!locked;
|
|
162
|
+
actions.appendChild(d);
|
|
163
|
+
}
|
|
164
|
+
const cancel = h(doc, 'button', 'btn btn-ghost btn-mini mem-cancel', 'Cancel'); cancel.type = 'button';
|
|
165
|
+
const save = h(doc, 'button', 'btn btn-primary btn-mini mem-save', 'Save'); save.type = 'button'; save.disabled = !!locked;
|
|
166
|
+
actions.append(cancel, save);
|
|
167
|
+
wrap.appendChild(actions);
|
|
168
|
+
return wrap;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function collectEditor(rootEl) {
|
|
172
|
+
return { name: String(rootEl.querySelector('.mem-name')?.value || '').trim(), text: String(rootEl.querySelector('.mem-text')?.value ?? '') };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The snapshot ring, newest first, one Restore per row (disabled while a defragment run is live). */
|
|
176
|
+
export function renderMemoryHistory(snapshots, { doc = globalThis.document, locked = false } = {}) {
|
|
177
|
+
const wrap = h(doc, 'div', 'mem-history');
|
|
178
|
+
wrap.appendChild(h(doc, 'div', 'mem-list-title', 'History'));
|
|
179
|
+
const list = Array.isArray(snapshots) ? [...snapshots].reverse() : [];
|
|
180
|
+
if (!list.length) { wrap.appendChild(h(doc, 'div', 'hist-empty', 'No snapshots yet — every write takes one.')); return wrap; }
|
|
181
|
+
for (const s of list) {
|
|
182
|
+
const row = h(doc, 'div', 'mem-snap');
|
|
183
|
+
row.dataset.id = s.id;
|
|
184
|
+
row.appendChild(h(doc, 'span', 'mem-snap-id mono', s.id));
|
|
185
|
+
row.appendChild(h(doc, 'span', 'mem-snap-count', plural((s.files || []).length, 'file')));
|
|
186
|
+
const btn = h(doc, 'button', 'btn btn-ghost btn-mini mem-restore', 'Restore');
|
|
187
|
+
btn.type = 'button'; btn.dataset.id = s.id; btn.disabled = !!locked;
|
|
188
|
+
row.appendChild(btn);
|
|
189
|
+
wrap.appendChild(row);
|
|
190
|
+
}
|
|
191
|
+
return wrap;
|
|
192
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// ui/public/node-tunables.mjs — PURE tunables resolution (no DOM, no fetch),
|
|
2
|
+
// shared by the New Pipeline agents accordion (app.js) and the Ask Worca run card
|
|
3
|
+
// (ask-panel.mjs). The two panels must never drift: this is the one definition.
|
|
4
|
+
// Moved verbatim out of app.js (2026-09-06); app.js keeps thin wrappers that add
|
|
5
|
+
// its own ports source (panelPortsFn, which can fall back to the Composer index).
|
|
6
|
+
import { classifyLoops } from '../../src/shared/graph/loops.mjs';
|
|
7
|
+
import { portsFnFor } from '../../src/shared/graph/ports.mjs';
|
|
8
|
+
|
|
9
|
+
// Flatten workflow.steps[][] into an ordered list of node rows, joining each
|
|
10
|
+
// node's role `key` to its registry metadata (label/color) and resolving every
|
|
11
|
+
// setting through the four layers (newpipeline-ux-design.md §4.3):
|
|
12
|
+
// 1. the per-project override — run-config nodes[nodeId], or, for the built-in
|
|
13
|
+
// Default workflow, the legacy per-role opts.legacySteps[key];
|
|
14
|
+
// 2. the workflow's own node.defaults;
|
|
15
|
+
// 3. the agent-registry sidecar (fanOut / questionsDefault);
|
|
16
|
+
// 4. nothing configured — the CLI default.
|
|
17
|
+
// Order = outer (sequential) then inner (parallel) — exactly the dispatch order.
|
|
18
|
+
//
|
|
19
|
+
// model/effort/fanOut/askQuestions on the returned row are the EFFECTIVE values
|
|
20
|
+
// (what the run will use). `def` carries the same four resolved WITHOUT layer 1,
|
|
21
|
+
// so the renderer can mark deviation and the writer can prune a redundant save
|
|
22
|
+
// back to "inherit". `override` is layer 1 verbatim.
|
|
23
|
+
export function buildNodeConfigRows(workflow, registry, runConfig, opts = {}) {
|
|
24
|
+
if (workflow && workflow.version === 2) return buildGraphNodeRows(workflow, registry, runConfig, opts);
|
|
25
|
+
const steps = Array.isArray(workflow && workflow.steps) ? workflow.steps : [];
|
|
26
|
+
const reg = registry || {};
|
|
27
|
+
const nodes = (runConfig && runConfig.nodes) || {};
|
|
28
|
+
const legacySteps = opts.legacySteps || null; // wf_default only: per-ROLE storage
|
|
29
|
+
const rows = [];
|
|
30
|
+
steps.forEach((group, stepIndex) => {
|
|
31
|
+
const members = Array.isArray(group) ? group : [];
|
|
32
|
+
members.forEach((node) => {
|
|
33
|
+
if (!node || !node.id) return;
|
|
34
|
+
const meta = reg[node.key] || null;
|
|
35
|
+
// The Default workflow's overrides live under the role key; a saved
|
|
36
|
+
// workflow's under the node-instance id. Both can exist for wf_default
|
|
37
|
+
// (a node write wins, mirroring resolveWorkflow's firstDefined order).
|
|
38
|
+
const role = legacySteps ? node.key : null;
|
|
39
|
+
const saved = { ...(role ? legacySteps[role] : null), ...nodes[node.id] };
|
|
40
|
+
const wfDef = (node.defaults && typeof node.defaults === 'object') ? node.defaults : {};
|
|
41
|
+
const metaFan = meta && typeof meta.fanOut === 'boolean' ? meta.fanOut : false;
|
|
42
|
+
const metaAsks = !!(meta && meta.asksQuestions);
|
|
43
|
+
const metaLocked = !!(meta && meta.questionsLocked);
|
|
44
|
+
const metaQDefault = !!(meta && meta.questionsDefault);
|
|
45
|
+
|
|
46
|
+
const t = resolveNodeTunables(saved, wfDef, { fanOut: metaFan, questionsDefault: metaQDefault });
|
|
47
|
+
|
|
48
|
+
rows.push({
|
|
49
|
+
nodeId: node.id,
|
|
50
|
+
key: node.key,
|
|
51
|
+
role, // non-null => persist via the legacy per-role path (saveStep)
|
|
52
|
+
label: (meta && meta.displayName) || node.key || node.id,
|
|
53
|
+
color: (meta && meta.color) || '',
|
|
54
|
+
description: (meta && meta.description) || '',
|
|
55
|
+
stepIndex,
|
|
56
|
+
parallel: members.length > 1,
|
|
57
|
+
model: t.model,
|
|
58
|
+
effort: t.effort,
|
|
59
|
+
fanOut: t.fanOut,
|
|
60
|
+
subagentModel: t.subagentModel,
|
|
61
|
+
// null => the agent has no questions capability (no checkbox rendered).
|
|
62
|
+
askQuestions: !metaAsks ? null : (metaLocked ? metaQDefault : t.askQuestions),
|
|
63
|
+
questionsLocked: metaAsks && metaLocked,
|
|
64
|
+
def: t.def,
|
|
65
|
+
override: t.override,
|
|
66
|
+
// A locked questions toggle is never the user's doing, so it never counts
|
|
67
|
+
// as a modification (it cannot be reset either).
|
|
68
|
+
modified: modifiedFieldsOf(t, t.def,
|
|
69
|
+
{ asksQuestions: metaAsks, questionsLocked: metaLocked }).length > 0,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
return rows;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ONE resolution rule for a node's five tunables, shared verbatim by the v1
|
|
77
|
+
// (buildNodeConfigRows) and v2 (buildGraphNodeRows) row builders — the two
|
|
78
|
+
// panels must never drift (the v2 path is the one every live workflow uses).
|
|
79
|
+
// `saved` = the per-project override entry (role + node merged), `wfDef` = the
|
|
80
|
+
// workflow's own defaults block (node.defaults in v1, node.config in v2),
|
|
81
|
+
// `caps` = the registry meta's capability booleans.
|
|
82
|
+
export function resolveNodeTunables(saved, wfDef, caps = {}) {
|
|
83
|
+
const override = {};
|
|
84
|
+
if (typeof saved.model === 'string' && saved.model) override.model = saved.model;
|
|
85
|
+
if (typeof saved.effort === 'string' && saved.effort) override.effort = saved.effort;
|
|
86
|
+
if (typeof saved.fanOut === 'boolean') override.fanOut = saved.fanOut;
|
|
87
|
+
if (typeof saved.askQuestions === 'boolean') override.askQuestions = saved.askQuestions;
|
|
88
|
+
if (typeof saved.subagentModel === 'string' && saved.subagentModel) override.subagentModel = saved.subagentModel;
|
|
89
|
+
|
|
90
|
+
// Layers 2-4 alone: what this row falls back to once its override is gone.
|
|
91
|
+
const def = {
|
|
92
|
+
model: typeof wfDef.model === 'string' ? wfDef.model : '',
|
|
93
|
+
effort: typeof wfDef.model === 'string' && typeof wfDef.effort === 'string' ? wfDef.effort : '',
|
|
94
|
+
fanOut: typeof wfDef.fanOut === 'boolean' ? wfDef.fanOut : !!caps.fanOut,
|
|
95
|
+
askQuestions: typeof wfDef.askQuestions === 'boolean' ? wfDef.askQuestions : !!caps.questionsDefault,
|
|
96
|
+
// No sidecar layer: an agent manifest declares whether a node CAN fan out,
|
|
97
|
+
// never what its children run on. '' = unset (the run resolves auto).
|
|
98
|
+
subagentModel: typeof wfDef.subagentModel === 'string' ? wfDef.subagentModel : '',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// An effort is only meaningful for the model that advertises it, so an
|
|
102
|
+
// override naming its own model does not inherit the default's effort.
|
|
103
|
+
const model = override.model !== undefined ? override.model : def.model;
|
|
104
|
+
const effort = override.effort !== undefined
|
|
105
|
+
? override.effort
|
|
106
|
+
: (override.model !== undefined ? '' : def.effort);
|
|
107
|
+
const fanOut = override.fanOut !== undefined ? override.fanOut : def.fanOut;
|
|
108
|
+
const askQuestions = override.askQuestions !== undefined ? override.askQuestions : def.askQuestions;
|
|
109
|
+
const subagentModel = override.subagentModel !== undefined ? override.subagentModel : def.subagentModel;
|
|
110
|
+
return { override, def, model, effort, fanOut, askQuestions, subagentModel };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Which of the five settings deviate from the row's resolved default. Pure; the
|
|
114
|
+
// single definition of "modified" for both the row dot and the header count.
|
|
115
|
+
export function modifiedFieldsOf(effective, def, caps = {}) {
|
|
116
|
+
const out = [];
|
|
117
|
+
if ((effective.model || '') !== (def.model || '')) out.push('model');
|
|
118
|
+
if ((effective.effort || '') !== (def.effort || '')) out.push('effort');
|
|
119
|
+
if (!!effective.fanOut !== !!def.fanOut) out.push('fanOut');
|
|
120
|
+
if ((effective.subagentModel || '') !== (def.subagentModel || '')) out.push('subagentModel');
|
|
121
|
+
if (caps.asksQuestions && !caps.questionsLocked && !!effective.askQuestions !== !!def.askQuestions) {
|
|
122
|
+
out.push('askQuestions');
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Prune a row's would-be selection against its resolved default (§4.5): a value
|
|
128
|
+
// equal to the default is stored as "inherit" instead — '' clears a model/effort,
|
|
129
|
+
// null clears a boolean toggle (config.mjs#inheritOr). Returns the patch to send.
|
|
130
|
+
// `next` carries only the fields the caller is changing; the rest ride along at
|
|
131
|
+
// their current effective value so the setters' replace semantics cannot wipe them.
|
|
132
|
+
export function pruneNodeSelection(row, next = {}) {
|
|
133
|
+
const eff = {
|
|
134
|
+
model: next.model !== undefined ? next.model : row.model,
|
|
135
|
+
effort: next.effort !== undefined ? next.effort : row.effort,
|
|
136
|
+
fanOut: next.fanOut !== undefined ? next.fanOut : row.fanOut,
|
|
137
|
+
askQuestions: next.askQuestions !== undefined ? next.askQuestions : row.askQuestions,
|
|
138
|
+
subagentModel: next.subagentModel !== undefined ? next.subagentModel : row.subagentModel,
|
|
139
|
+
};
|
|
140
|
+
// model+effort prune as a PAIR: an effort is only interpretable against the
|
|
141
|
+
// model that advertises it, so storing one without the other is rejected by
|
|
142
|
+
// the setters ("select a model before choosing an effort").
|
|
143
|
+
const inheritPair = (eff.model || '') === (row.def.model || '')
|
|
144
|
+
&& (eff.effort || '') === (row.def.effort || '');
|
|
145
|
+
return {
|
|
146
|
+
model: inheritPair ? '' : (eff.model || ''),
|
|
147
|
+
effort: inheritPair || !eff.model ? '' : eff.effort,
|
|
148
|
+
fanOut: !!eff.fanOut === !!row.def.fanOut ? null : !!eff.fanOut,
|
|
149
|
+
askQuestions: row.askQuestions === null || row.questionsLocked
|
|
150
|
+
? undefined // no capability / locked: never persist a value for it
|
|
151
|
+
: (!!eff.askQuestions === !!row.def.askQuestions ? null : !!eff.askQuestions),
|
|
152
|
+
// '' IS the clear for a string tunable (config.mjs#inheritOrSubagentModel), so
|
|
153
|
+
// a value equal to the default prunes to inherit exactly like model/effort.
|
|
154
|
+
subagentModel: (eff.subagentModel || '') === (row.def.subagentModel || '') ? '' : (eff.subagentModel || ''),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// v2: agent nodes only, in condensation-topo launch order (loop wires excluded
|
|
159
|
+
// from the ranking, exactly as the scheduler orders launches). The four config
|
|
160
|
+
// layers are the same as v1: run-config nodes[nodeId] -> template node.config ->
|
|
161
|
+
// sidecar -> hard default.
|
|
162
|
+
export function buildGraphNodeRows(tpl, registry, runConfig, opts = {}) {
|
|
163
|
+
const reg = registry || {};
|
|
164
|
+
const nodes = (runConfig && runConfig.nodes) || {};
|
|
165
|
+
// wf_default only: the legacy per-ROLE storage, layered under the per-node one
|
|
166
|
+
// exactly as resolveGraph does (sel -> legacy -> node config).
|
|
167
|
+
const legacySteps = opts.legacySteps || null;
|
|
168
|
+
// app.js hands its own panelPortsFn (falls back to the Composer's index for
|
|
169
|
+
// agents the palette omits); the card and the tests use the registry alone.
|
|
170
|
+
const portsFn = typeof opts.portsFn === 'function' ? opts.portsFn : portsFnFor(reg);
|
|
171
|
+
const order = classifyLoops(tpl, portsFn).launchOrder;
|
|
172
|
+
const byId = new Map(tpl.nodes.map((n) => [n.id, n]));
|
|
173
|
+
const rank = new Map(order.map((id, i) => [id, i]));
|
|
174
|
+
const agentNodes = order.map((id) => byId.get(id)).filter((n) => n && n.kind === 'agent');
|
|
175
|
+
const rows = [];
|
|
176
|
+
for (const node of agentNodes) {
|
|
177
|
+
const meta = reg[node.key] || null;
|
|
178
|
+
const role = legacySteps ? node.key : null;
|
|
179
|
+
const saved = { ...(role ? legacySteps[role] : null), ...nodes[node.id] };
|
|
180
|
+
const wfDef = (node.config && typeof node.config === 'object') ? node.config : {};
|
|
181
|
+
const metaFan = meta && typeof meta.fanOut === 'boolean' ? meta.fanOut : false;
|
|
182
|
+
const metaAsks = !!(meta && meta.asksQuestions);
|
|
183
|
+
const metaLocked = !!(meta && meta.questionsLocked);
|
|
184
|
+
const metaQDefault = !!(meta && meta.questionsDefault);
|
|
185
|
+
const t = resolveNodeTunables(saved, wfDef, { fanOut: metaFan, questionsDefault: metaQDefault });
|
|
186
|
+
rows.push({
|
|
187
|
+
nodeId: node.id, key: node.key, role, // non-null => persist via saveStep (wf_default)
|
|
188
|
+
label: (meta && meta.displayName) || node.key || node.id,
|
|
189
|
+
color: (meta && meta.color) || '', description: (meta && meta.description) || '',
|
|
190
|
+
stepIndex: rank.get(node.id) || 0,
|
|
191
|
+
parallel: false,
|
|
192
|
+
model: t.model, effort: t.effort, fanOut: t.fanOut, subagentModel: t.subagentModel,
|
|
193
|
+
askQuestions: !metaAsks ? null : (metaLocked ? metaQDefault : t.askQuestions),
|
|
194
|
+
questionsLocked: metaAsks && metaLocked,
|
|
195
|
+
def: t.def, override: t.override,
|
|
196
|
+
modified: modifiedFieldsOf(t, t.def,
|
|
197
|
+
{ asksQuestions: metaAsks, questionsLocked: metaLocked }).length > 0,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return rows;
|
|
201
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// ui/public/report-run.mjs
|
|
2
|
+
// Pure renderers for the "Report this run" preview modal. No fetch, no listeners,
|
|
3
|
+
// no document lookups: every function takes { doc } and returns DETACHED nodes, the
|
|
4
|
+
// way plugins-view.mjs / guardrails-view.mjs do. app.js owns the modal, the network
|
|
5
|
+
// call, the clipboard and the download.
|
|
6
|
+
//
|
|
7
|
+
// The reason and opt-in vocabulary comes from src/shared/report-reasons.mjs — the
|
|
8
|
+
// SAME module the core builder imports, so a label and a payload cannot drift. Two
|
|
9
|
+
// `..` because this file sits two levels below the repo root; the browser clamps the
|
|
10
|
+
// URL at '/' and the server serves src/shared there. Absolute specifiers are
|
|
11
|
+
// FORBIDDEN — they break the Node ESM resolver the UI tests use; app.js already uses
|
|
12
|
+
// this exact prefix.
|
|
13
|
+
import { REPORT_REASONS, OPT_IN_CLASSES } from '../../src/shared/report-reasons.mjs';
|
|
14
|
+
|
|
15
|
+
/** <option> elements for the reason <select>, in menu order. */
|
|
16
|
+
export function renderReasonOptions({ doc = globalThis.document } = {}) {
|
|
17
|
+
return REPORT_REASONS.map((reason) => {
|
|
18
|
+
const opt = doc.createElement('option');
|
|
19
|
+
opt.value = reason.id;
|
|
20
|
+
opt.textContent = reason.label;
|
|
21
|
+
return opt;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The three opt-in checkboxes, as .confirm-checkbox rows (the #export-modal shape).
|
|
27
|
+
* Each input carries data-optin="<key>" so app.js can delegate one change listener
|
|
28
|
+
* over the wrapper.
|
|
29
|
+
*
|
|
30
|
+
* The sub-label class is .report-optin-hint, NOT .hint: ui-settings-about.test.mjs
|
|
31
|
+
* asserts every `.hint` in the settings view is empty-texted, and keeping a
|
|
32
|
+
* non-empty .hint out of the codebase's shared vocabulary avoids ever tripping that
|
|
33
|
+
* view-wide rule if this markup is later reused inside Settings.
|
|
34
|
+
*/
|
|
35
|
+
export function renderOptIns({ doc = globalThis.document, include = {} } = {}) {
|
|
36
|
+
const wrap = doc.createElement('div');
|
|
37
|
+
wrap.className = 'report-optins';
|
|
38
|
+
for (const cls of OPT_IN_CLASSES) {
|
|
39
|
+
const label = doc.createElement('label');
|
|
40
|
+
label.className = 'confirm-checkbox';
|
|
41
|
+
|
|
42
|
+
const input = doc.createElement('input');
|
|
43
|
+
input.type = 'checkbox';
|
|
44
|
+
input.dataset.optin = cls.key;
|
|
45
|
+
input.checked = include[cls.key] === true;
|
|
46
|
+
|
|
47
|
+
const span = doc.createElement('span');
|
|
48
|
+
span.textContent = cls.label;
|
|
49
|
+
const hint = doc.createElement('span');
|
|
50
|
+
hint.className = 'report-optin-hint';
|
|
51
|
+
hint.textContent = cls.hint;
|
|
52
|
+
span.appendChild(doc.createElement('br'));
|
|
53
|
+
span.appendChild(hint);
|
|
54
|
+
|
|
55
|
+
label.appendChild(input);
|
|
56
|
+
label.appendChild(span);
|
|
57
|
+
wrap.appendChild(label);
|
|
58
|
+
}
|
|
59
|
+
return wrap;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The preview text. This is the contract of the whole feature: what the reporter
|
|
64
|
+
* reads here is byte-identical to what Copy JSON puts on the clipboard and what
|
|
65
|
+
* Download JSON writes.
|
|
66
|
+
*/
|
|
67
|
+
export function previewText(payload) {
|
|
68
|
+
if (!payload) return 'Building the report…';
|
|
69
|
+
return JSON.stringify(payload, null, 2);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Blob parts for Download JSON — pretty JSON with a trailing newline. */
|
|
73
|
+
export function reportBlobParts(payload) {
|
|
74
|
+
return [`${JSON.stringify(payload, null, 2)}\n`];
|
|
75
|
+
}
|
|
@@ -119,3 +119,28 @@ export function workflowPickerLabel(wf, enabledPluginNames = []) {
|
|
|
119
119
|
const disabled = Array.isArray(enabledPluginNames) && !enabledPluginNames.includes(plugin);
|
|
120
120
|
return `${wf.name} [plugin: ${plugin}${disabled ? ' — disabled' : ''}]`;
|
|
121
121
|
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Rows for the History Overview "Memory changes" block (agent-memory-design.md §6).
|
|
125
|
+
* Pure: `memory` is results.json.memory / the detail's `memory` ({ changes }).
|
|
126
|
+
* One row per change entry that carries at least one file; chips in the order
|
|
127
|
+
* added, modified, deleted, rejected; a rejected chip's `title` is the reason.
|
|
128
|
+
* Each chip also carries the ref it was built from — `scope` (mount-relative:
|
|
129
|
+
* `global` | `project` | `projects/<key>`) and `name` — so History can link to
|
|
130
|
+
* the file in its Memory view (B6).
|
|
131
|
+
*/
|
|
132
|
+
export function memoryChangesRows(memory) {
|
|
133
|
+
const changes = Array.isArray(memory?.changes) ? memory.changes : [];
|
|
134
|
+
const file = (r) => `${r.scope}/${r.name}.md`;
|
|
135
|
+
const rows = [];
|
|
136
|
+
for (const c of changes) {
|
|
137
|
+
const chips = [
|
|
138
|
+
...(c.added || []).map((r) => ({ kind: 'add', text: `+ ${file(r)}`, scope: r.scope, name: r.name })),
|
|
139
|
+
...(c.modified || []).map((r) => ({ kind: 'mod', text: `~ ${file(r)}`, scope: r.scope, name: r.name })),
|
|
140
|
+
...(c.deleted || []).map((r) => ({ kind: 'del', text: `− ${file(r)}`, scope: r.scope, name: r.name })),
|
|
141
|
+
...(c.rejected || []).map((r) => ({ kind: 'rej', text: `✕ ${file(r)}`, title: String(r.reason || ''), scope: r.scope, name: r.name })),
|
|
142
|
+
];
|
|
143
|
+
if (chips.length) rows.push({ node: c.agentKey || c.nodeId || 'run', chips });
|
|
144
|
+
}
|
|
145
|
+
return rows;
|
|
146
|
+
}
|
|
@@ -23,6 +23,20 @@ export function debounce(fn, ms, timers = {}) {
|
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// A task body is markdown (sources.mjs writes it under `# title` into the prompt).
|
|
27
|
+
// `renderMarkdown(text)` — injected by app.js, the page's sanitized pipeline — returns
|
|
28
|
+
// { kind: 'md', frag } or { kind: 'plain' }; without it, or before it is ready, the
|
|
29
|
+
// body is a verbatim <pre>.
|
|
30
|
+
function taskBody(doc, text, renderMarkdown) {
|
|
31
|
+
const out = text && typeof renderMarkdown === 'function' ? renderMarkdown(text) : null;
|
|
32
|
+
if (out && out.kind === 'md' && out.frag) {
|
|
33
|
+
const box = h(doc, 'div', 'sp-prev-body artifact-markdown');
|
|
34
|
+
box.appendChild(out.frag);
|
|
35
|
+
return box;
|
|
36
|
+
}
|
|
37
|
+
return h(doc, 'pre', 'sp-prev-body', text);
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
// Values of every non-task-browser input in the pane, keyed by input key —
|
|
27
41
|
// these travel as `inputs` to connector ops and into body.source at submit.
|
|
28
42
|
function collectInputs(pane) {
|
|
@@ -100,7 +114,7 @@ function taskRow(doc, t, now) {
|
|
|
100
114
|
// dropdown populated ONCE on first focus via call(optionsFrom) (promise kept on
|
|
101
115
|
// ._load for deterministic tests); task-browser -> debounced (300ms) search +
|
|
102
116
|
// result list + preview (call getTask on pick) + hidden selected taskId.
|
|
103
|
-
export function renderSourcePane(source, { call, doc = globalThis.document, timers, now } = {}) {
|
|
117
|
+
export function renderSourcePane(source, { call, doc = globalThis.document, timers, now, renderMarkdown } = {}) {
|
|
104
118
|
const clock = typeof now === 'function' ? now : () => Date.now();
|
|
105
119
|
const pane = h(doc, 'div', 'sp-pane');
|
|
106
120
|
pane.dataset.plugin = source.plugin;
|
|
@@ -207,7 +221,7 @@ export function renderSourcePane(source, { call, doc = globalThis.document, time
|
|
|
207
221
|
const task = await call('getTask', { id: row.dataset.taskId });
|
|
208
222
|
preview.replaceChildren(
|
|
209
223
|
h(doc, 'b', 'sp-prev-title', (task && task.title) || row.dataset.taskId),
|
|
210
|
-
|
|
224
|
+
taskBody(doc, (task && task.body) || '', renderMarkdown),
|
|
211
225
|
);
|
|
212
226
|
})().catch((e) => { preview.textContent = `preview failed: ${e.message}`; });
|
|
213
227
|
});
|
package/ui/public/stats-view.mjs
CHANGED
|
@@ -373,7 +373,7 @@ const PW = CW - L - R, PH = CH - T - B;
|
|
|
373
373
|
* integer=true (runs chart) keeps ticks on whole numbers — a sub-1 or ×2.5
|
|
374
374
|
* step on tiny count maxima would render duplicate rounded labels ("0, 1, 1"
|
|
375
375
|
* for a 1-run day) or off-grid labels ("3" at the 2.5 line). */
|
|
376
|
-
function niceScale(maxVal, integer = false) {
|
|
376
|
+
export function niceScale(maxVal, integer = false) {
|
|
377
377
|
const m = maxVal > 0 ? maxVal : 1;
|
|
378
378
|
const raw = m / 3; // aim for ~3-4 ticks
|
|
379
379
|
const pow = 10 ** Math.floor(Math.log10(raw));
|
|
@@ -384,7 +384,7 @@ function niceScale(maxVal, integer = false) {
|
|
|
384
384
|
}
|
|
385
385
|
|
|
386
386
|
/** Column path with a rounded TOP only (square baseline). r clamps to h/2. */
|
|
387
|
-
function roundedTopBar(x, y, w, hgt, r = 4) {
|
|
387
|
+
export function roundedTopBar(x, y, w, hgt, r = 4) {
|
|
388
388
|
const rr = Math.max(0, Math.min(r, hgt / 2, w / 2));
|
|
389
389
|
return `M${x},${y + hgt} L${x},${y + rr} Q${x},${y} ${x + rr},${y} L${x + w - rr},${y} ` +
|
|
390
390
|
`Q${x + w},${y} ${x + w},${y + rr} L${x + w},${y + hgt} Z`;
|