@worca/app 1.0.0 → 1.2.0-rc.1
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 +30 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +386 -56
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +199 -23
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +545 -0
- package/src/core/ask/follow.mjs +113 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +57 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +286 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +156 -0
- package/src/core/ask/store.mjs +438 -0
- package/src/core/ask/tool-deps.mjs +87 -0
- package/src/core/ask/tools.mjs +879 -0
- package/src/core/ask/turn.mjs +462 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +28 -7
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +541 -62
- package/src/core/config.mjs +310 -44
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +773 -53
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1072 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +180 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +994 -4097
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +80 -17
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3934 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +184 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4240 -1682
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +317 -0
- package/ui/public/ask-panel.mjs +2129 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +311 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1487 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1894 -104
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// ui/public/ask-markdown.mjs — sandboxed markdown for Ask Worca answers
|
|
2
|
+
// (spec §10.7). marked + DOMPurify are lazy-loaded through the injected
|
|
3
|
+
// `load()` (the app wires it to the P2 vendor routes; tests import the same
|
|
4
|
+
// pinned packages directly). Failure latches to plain text after three
|
|
5
|
+
// attempts — the MAX_RESOURCE_FAILURES precedent of hljs-loader.mjs — never an
|
|
6
|
+
// endless retry. Code blocks are highlighted only on ask-done, with the same
|
|
7
|
+
// detached-staging commit rules as hdApplyHighlights (app.js:11235-11260):
|
|
8
|
+
// text must round-trip byte-for-byte and only class-carrying SPANs may appear.
|
|
9
|
+
import { SUPPORTED_LANGUAGE_IDS } from './syntax-highlight.mjs';
|
|
10
|
+
|
|
11
|
+
const ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'del', 'code', 'pre', 'ul', 'ol', 'li', 'a',
|
|
12
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'input'];
|
|
13
|
+
const ALLOWED_ATTR = ['href', 'class', 'type', 'checked', 'disabled', 'align'];
|
|
14
|
+
const ALLOWED_URI_REGEXP = /^(?:https?:|mailto:|#)/i;
|
|
15
|
+
// DOMPurify tests EVERY non-URI-safe attribute VALUE against ALLOWED_URI_REGEXP;
|
|
16
|
+
// with the strict regexp above, type="checkbox" and align="right" would be
|
|
17
|
+
// dropped (only `class` is URI-safe by default) — the post-pass would then
|
|
18
|
+
// remove every checkbox. These two carry no URL, so marking them URI-safe
|
|
19
|
+
// restores them without loosening the href guard.
|
|
20
|
+
const ADD_URI_SAFE_ATTR = ['type', 'align'];
|
|
21
|
+
const CODE_CLASS_RE = /^language-[A-Za-z0-9_+-]{1,64}$/;
|
|
22
|
+
// hljs primary tokens plus the secondary `word_` scope tokens it appends — a
|
|
23
|
+
// per-token variant of the attribute-level regex at syntax-highlight.mjs:49
|
|
24
|
+
// (that one requires an hljs-* first token per attribute; this one is checked
|
|
25
|
+
// per classList ENTRY, so a `word_` token may stand alone — a deliberate,
|
|
26
|
+
// harmless superset: spans still carry classes only).
|
|
27
|
+
const HLJS_CLASS_RE = /^(?:hljs-[A-Za-z0-9_-]+|[A-Za-z0-9-]+_+)$/;
|
|
28
|
+
const PLAIN_LIMIT = 200_000;
|
|
29
|
+
const MAX_ATTEMPTS = 3;
|
|
30
|
+
|
|
31
|
+
export const LANGUAGE_ALIASES = Object.freeze({
|
|
32
|
+
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', node: 'javascript',
|
|
33
|
+
ts: 'typescript', tsx: 'typescript',
|
|
34
|
+
sh: 'bash', shell: 'bash', zsh: 'bash', console: 'bash',
|
|
35
|
+
yml: 'yaml', html: 'xml', htm: 'xml', svg: 'xml', vue: 'xml',
|
|
36
|
+
py: 'python', rb: 'ruby', md: 'markdown', golang: 'go', 'c++': 'cpp', cs: 'csharp', patch: 'diff', toml: 'ini',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const SUPPORTED = new Set(SUPPORTED_LANGUAGE_IDS);
|
|
40
|
+
|
|
41
|
+
export function createMarkdownRenderer({ doc, load, hljsLoader }) {
|
|
42
|
+
let mods = null;
|
|
43
|
+
let loading = null;
|
|
44
|
+
let attempts = 0;
|
|
45
|
+
let failed = false;
|
|
46
|
+
|
|
47
|
+
function ensure() {
|
|
48
|
+
if (mods) return Promise.resolve(true);
|
|
49
|
+
if (failed) return Promise.resolve(false);
|
|
50
|
+
if (!loading) {
|
|
51
|
+
loading = Promise.resolve()
|
|
52
|
+
.then(() => load())
|
|
53
|
+
.then((loaded) => {
|
|
54
|
+
const marked = loaded && loaded.marked;
|
|
55
|
+
const createDOMPurify = loaded && loaded.createDOMPurify;
|
|
56
|
+
const purifier = typeof createDOMPurify === 'function' ? createDOMPurify(doc.defaultView) : null;
|
|
57
|
+
if (!marked || typeof marked.parse !== 'function' || !purifier || typeof purifier.sanitize !== 'function') {
|
|
58
|
+
throw new Error('markdown modules have an unexpected shape');
|
|
59
|
+
}
|
|
60
|
+
mods = { marked, purifier };
|
|
61
|
+
return true;
|
|
62
|
+
})
|
|
63
|
+
.catch(() => {
|
|
64
|
+
attempts += 1;
|
|
65
|
+
loading = null;
|
|
66
|
+
if (attempts >= MAX_ATTEMPTS) failed = true;
|
|
67
|
+
return false;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return loading;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function render(text) {
|
|
74
|
+
const s = String(text ?? '');
|
|
75
|
+
if (!mods || failed || s.length > PLAIN_LIMIT) return { kind: 'plain' };
|
|
76
|
+
let html;
|
|
77
|
+
try {
|
|
78
|
+
html = mods.marked.parse(s, { gfm: true, breaks: true, async: false });
|
|
79
|
+
} catch {
|
|
80
|
+
return { kind: 'plain' };
|
|
81
|
+
}
|
|
82
|
+
let frag;
|
|
83
|
+
try {
|
|
84
|
+
frag = mods.purifier.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR, ALLOWED_URI_REGEXP, ADD_URI_SAFE_ATTR, RETURN_DOM_FRAGMENT: true });
|
|
85
|
+
} catch {
|
|
86
|
+
return { kind: 'plain' };
|
|
87
|
+
}
|
|
88
|
+
for (const node of [...frag.querySelectorAll('[class]')]) {
|
|
89
|
+
const tag = node.tagName;
|
|
90
|
+
const keep = [...node.classList].filter((c) => (
|
|
91
|
+
tag === 'CODE' ? CODE_CLASS_RE.test(c) : tag === 'SPAN' ? HLJS_CLASS_RE.test(c) : false
|
|
92
|
+
));
|
|
93
|
+
if (keep.length) node.setAttribute('class', keep.join(' '));
|
|
94
|
+
else node.removeAttribute('class');
|
|
95
|
+
}
|
|
96
|
+
for (const a of [...frag.querySelectorAll('a[href]')]) {
|
|
97
|
+
const href = a.getAttribute('href') || '';
|
|
98
|
+
if (/^(?:https?:|mailto:)/i.test(href)) {
|
|
99
|
+
a.setAttribute('target', '_blank');
|
|
100
|
+
a.setAttribute('rel', 'noopener noreferrer');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const input of [...frag.querySelectorAll('input')]) {
|
|
104
|
+
if ((input.getAttribute('type') || '').toLowerCase() !== 'checkbox') { input.remove(); continue; }
|
|
105
|
+
input.setAttribute('disabled', '');
|
|
106
|
+
}
|
|
107
|
+
return { kind: 'md', frag };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function highlight(container) {
|
|
111
|
+
if (!container || !hljsLoader || failed) return;
|
|
112
|
+
for (const code of [...container.querySelectorAll('pre > code')]) {
|
|
113
|
+
const cls = [...code.classList].find((c) => c.startsWith('language-'));
|
|
114
|
+
if (!cls) continue;
|
|
115
|
+
const raw = cls.slice('language-'.length).toLowerCase();
|
|
116
|
+
const lang = SUPPORTED.has(raw) ? raw : LANGUAGE_ALIASES[raw];
|
|
117
|
+
if (!lang || !SUPPORTED.has(lang)) continue;
|
|
118
|
+
let binding = null;
|
|
119
|
+
try { binding = await hljsLoader.forLanguage(lang); } catch { binding = null; }
|
|
120
|
+
if (!binding) continue;
|
|
121
|
+
const source = code.textContent;
|
|
122
|
+
let html = '';
|
|
123
|
+
try { html = binding.highlight(source); } catch { continue; }
|
|
124
|
+
// hdApplyHighlights staging rules, verbatim: detached holder, byte-exact
|
|
125
|
+
// text, SPAN-only markup whose only attribute is a valid class.
|
|
126
|
+
const holder = doc.createElement('span');
|
|
127
|
+
holder.innerHTML = html;
|
|
128
|
+
if (holder.textContent !== source) continue;
|
|
129
|
+
const els = [...holder.querySelectorAll('*')];
|
|
130
|
+
const bad = els.some((el) => el.tagName !== 'SPAN'
|
|
131
|
+
|| [...el.attributes].some((attr) => attr.name !== 'class')
|
|
132
|
+
|| [...el.classList].some((c) => !HLJS_CLASS_RE.test(c)));
|
|
133
|
+
if (bad) continue;
|
|
134
|
+
code.replaceChildren(...holder.childNodes);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return Object.freeze({
|
|
139
|
+
ensure,
|
|
140
|
+
isReady: () => !!mods,
|
|
141
|
+
isFailed: () => failed,
|
|
142
|
+
render,
|
|
143
|
+
highlight,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// ui/public/ask-model.mjs — DOM-free thread model + ask-* frame reducer for the
|
|
2
|
+
// Ask Worca panel (spec §10.1). One instance per open thread; the panel swaps
|
|
3
|
+
// instances on thread switch. Everything lives in the factory closure — the
|
|
4
|
+
// module is evaluated once per test file even though app.js is re-imported with
|
|
5
|
+
// a cache-buster, so module scope must stay empty of state.
|
|
6
|
+
//
|
|
7
|
+
// Frame classes (spec §6.6 / the P2→P3 contract): job frames carry
|
|
8
|
+
// {threadId, messageId, seq} and are deduped by the per-job monotonic seq;
|
|
9
|
+
// out-of-turn frames (ask-message / ask-title / ask-run-status / ask-worktrees)
|
|
10
|
+
// upsert by their own key. A seq gap is REPORTED ({gap:true}), never healed here — the panel
|
|
11
|
+
// re-fetches the thread over REST and resubscribes (spec §10.8).
|
|
12
|
+
|
|
13
|
+
const TERMINAL = new Set(['done', 'stopped', 'error']);
|
|
14
|
+
|
|
15
|
+
export function createThreadModel({ threadId }) {
|
|
16
|
+
let thread = { id: threadId, title: null, model: null, effort: null, totals: {}, updatedAt: null };
|
|
17
|
+
let rows = [];
|
|
18
|
+
let attachments = [];
|
|
19
|
+
const links = new Map();
|
|
20
|
+
let live = null;
|
|
21
|
+
let inFlight = null;
|
|
22
|
+
let dirty = newDirty();
|
|
23
|
+
let worktrees = []; // P4 §10: the chat's open worktrees (snapshot + ask-worktrees frames); the panel mirrors it
|
|
24
|
+
|
|
25
|
+
function newDirty() {
|
|
26
|
+
// runLinks dirt is produced but not yet consumed — no v1 UI renders run links directly; the follower notices carry the visible state.
|
|
27
|
+
return { structure: false, messages: new Set(), blocks: new Map(), answer: new Set(), label: false, meters: false, title: false, runLinks: false, worktrees: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rowById(id) {
|
|
31
|
+
for (const r of rows) if (r && r.id === id) return r;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Agent blocks on the LIVE row, unique by id (upsertBlock replaces in place).
|
|
36
|
+
// Finished turns are already inside thread.totals.agents; ask-done replaces
|
|
37
|
+
// those totals and nulls `live` in ONE frame, so the two never overlap.
|
|
38
|
+
function liveAgentCount() {
|
|
39
|
+
const row = live ? rowById(live.messageId) : null;
|
|
40
|
+
if (!row || !Array.isArray(row.blocks)) return 0;
|
|
41
|
+
let n = 0;
|
|
42
|
+
for (const b of row.blocks) if (b && b.kind === 'agent') n += 1;
|
|
43
|
+
return n;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function upsertRow(message) {
|
|
47
|
+
const i = rows.findIndex((r) => r && r.id === message.id);
|
|
48
|
+
if (i >= 0) {
|
|
49
|
+
// The POST-side ask-message broadcast can beat the local echo (same id):
|
|
50
|
+
// an in-place replace must not wipe the store seq it delivered.
|
|
51
|
+
if (typeof message.seq !== 'number' && typeof rows[i].seq === 'number') {
|
|
52
|
+
message = { ...message, seq: rows[i].seq };
|
|
53
|
+
}
|
|
54
|
+
rows[i] = message;
|
|
55
|
+
} else {
|
|
56
|
+
// Sorted insert orders KNOWN seqs only. Seq-less rows are live-created
|
|
57
|
+
// (echo / streaming assistant) and therefore newest — a numeric row must
|
|
58
|
+
// never slot above them; a new row otherwise appends.
|
|
59
|
+
const seq = typeof message.seq === 'number' ? message.seq : Infinity;
|
|
60
|
+
const at = rows.findIndex((r) => typeof r.seq === 'number' && r.seq > seq);
|
|
61
|
+
if (at === -1) rows.push(message);
|
|
62
|
+
else rows.splice(at, 0, message);
|
|
63
|
+
}
|
|
64
|
+
dirty.structure = true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The thread's attachment ledger (attachmentsBytes → the composer's budget
|
|
68
|
+
// pre-check) is seeded by the snapshot; without this it would never learn of
|
|
69
|
+
// an upload made in this session, and the composer would let a whole over-
|
|
70
|
+
// budget base64 POST through to the server's 413. Keyed by the store id, so a
|
|
71
|
+
// row seen through both the broadcast and the local echo counts once.
|
|
72
|
+
function noteAttachmentBlocks(blocks) {
|
|
73
|
+
for (const b of Array.isArray(blocks) ? blocks : []) {
|
|
74
|
+
if (!b || b.kind !== 'attachment' || typeof b.id !== 'string') continue;
|
|
75
|
+
if (attachments.some((a) => a && a.id === b.id)) continue;
|
|
76
|
+
attachments.push({ id: b.id, name: b.name, bytes: Number.isFinite(b.bytes) ? b.bytes : 0, kind: b.attKind ?? 'text', mime: b.mime ?? null });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function markBlockDirty(messageId, blockId) {
|
|
81
|
+
if (!dirty.blocks.has(messageId)) dirty.blocks.set(messageId, new Set());
|
|
82
|
+
dirty.blocks.get(messageId).add(blockId);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ensureStreamingRow(messageId, base = {}) {
|
|
86
|
+
let row = rowById(messageId);
|
|
87
|
+
if (!row) {
|
|
88
|
+
row = {
|
|
89
|
+
id: messageId, threadId, seq: undefined, role: 'assistant', text: '', blocks: [],
|
|
90
|
+
status: 'streaming', reason: null, model: base.model ?? null, effort: base.effort ?? null,
|
|
91
|
+
usage: null, costUsd: null, durationMs: null, createdAt: base.startedAt ?? null,
|
|
92
|
+
};
|
|
93
|
+
upsertRow(row);
|
|
94
|
+
}
|
|
95
|
+
if (!Array.isArray(row.blocks)) row.blocks = [];
|
|
96
|
+
return row;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function upsertBlock(row, block) {
|
|
100
|
+
const i = row.blocks.findIndex((b) => b && b.id === block.id);
|
|
101
|
+
if (i >= 0) row.blocks[i] = block;
|
|
102
|
+
else row.blocks.push(block);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function finalizeDone(row, frame) {
|
|
106
|
+
row.text = frame.text ?? '';
|
|
107
|
+
row.blocks = Array.isArray(frame.blocks) ? frame.blocks : row.blocks;
|
|
108
|
+
row.usage = frame.usage ?? null;
|
|
109
|
+
row.costUsd = frame.costUsd ?? null;
|
|
110
|
+
row.durationMs = frame.durationMs ?? null;
|
|
111
|
+
row.status = frame.status || 'done';
|
|
112
|
+
row.reason = frame.reason ?? null;
|
|
113
|
+
row.model = frame.model ?? row.model;
|
|
114
|
+
if (frame.threadTotals) { thread.totals = frame.threadTotals; }
|
|
115
|
+
live = null;
|
|
116
|
+
inFlight = null;
|
|
117
|
+
dirty.structure = true;
|
|
118
|
+
dirty.messages.add(row.id);
|
|
119
|
+
dirty.answer.add(row.id);
|
|
120
|
+
dirty.meters = true;
|
|
121
|
+
dirty.label = true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function applyJobFrame(frame) {
|
|
125
|
+
const existing = rowById(frame.messageId);
|
|
126
|
+
if (existing && TERMINAL.has(existing.status)) return { dropped: 'terminal-message' };
|
|
127
|
+
if (live && frame.messageId === live.messageId) {
|
|
128
|
+
if (frame.type === 'ask-start' && live.adopted && frame.seq < live.lastSeq) {
|
|
129
|
+
// The subscribe replay (seq 1..N) landing AFTER a stray broadcast frame was
|
|
130
|
+
// adopted at a high seq: rewind to the replayed start so the prefix is
|
|
131
|
+
// applied instead of dropped as stale (review of PR #376 — the answer
|
|
132
|
+
// rendered blank and Send never flipped to Stop). Text restarts from '';
|
|
133
|
+
// the replay re-delivers every delta, and blocks upsert by id.
|
|
134
|
+
live.lastSeq = frame.seq; live.text = ''; live.adopted = false;
|
|
135
|
+
live.userMessageId = frame.userMessageId ?? live.userMessageId;
|
|
136
|
+
live.startedAt = frame.startedAt ?? live.startedAt;
|
|
137
|
+
live.label = 'Thinking';
|
|
138
|
+
dirty.label = true;
|
|
139
|
+
} else {
|
|
140
|
+
if (frame.seq <= live.lastSeq) return { dropped: 'stale-seq' };
|
|
141
|
+
if (frame.seq > live.lastSeq + 1) return { gap: true };
|
|
142
|
+
live.lastSeq = frame.seq;
|
|
143
|
+
}
|
|
144
|
+
} else if (frame.type === 'ask-start') {
|
|
145
|
+
ensureStreamingRow(frame.messageId, frame);
|
|
146
|
+
live = { messageId: frame.messageId, userMessageId: frame.userMessageId ?? null, label: 'Thinking', startedAt: frame.startedAt ?? null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, estimatedCostUsd: null };
|
|
147
|
+
inFlight = { messageId: frame.messageId };
|
|
148
|
+
dirty.label = true;
|
|
149
|
+
} else if (!live && inFlight && frame.messageId === inFlight.messageId) {
|
|
150
|
+
// Adoption: the ring buffer may have evicted the prefix — accept the first
|
|
151
|
+
// frame at whatever seq it carries; ask-done.text heals the missing text.
|
|
152
|
+
ensureStreamingRow(frame.messageId);
|
|
153
|
+
live = { messageId: frame.messageId, userMessageId: null, label: null, startedAt: null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, estimatedCostUsd: null, adopted: true };
|
|
154
|
+
} else {
|
|
155
|
+
return { dropped: 'no-live' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const row = ensureStreamingRow(frame.messageId, frame);
|
|
159
|
+
switch (frame.type) {
|
|
160
|
+
case 'ask-start':
|
|
161
|
+
row.model = frame.model ?? row.model;
|
|
162
|
+
row.effort = frame.effort ?? row.effort;
|
|
163
|
+
break;
|
|
164
|
+
case 'ask-label':
|
|
165
|
+
live.label = frame.label;
|
|
166
|
+
dirty.label = true;
|
|
167
|
+
break;
|
|
168
|
+
case 'ask-delta':
|
|
169
|
+
live.text += String(frame.text ?? '');
|
|
170
|
+
dirty.answer.add(frame.messageId);
|
|
171
|
+
break;
|
|
172
|
+
case 'ask-block':
|
|
173
|
+
case 'ask-card':
|
|
174
|
+
if (frame.block && frame.block.id != null) {
|
|
175
|
+
upsertBlock(row, frame.block);
|
|
176
|
+
markBlockDirty(frame.messageId, frame.block.id);
|
|
177
|
+
if (frame.type === 'ask-block' && frame.block.kind === 'agent') dirty.meters = true; // "N agents" moves live
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
case 'ask-usage':
|
|
181
|
+
live.usage = frame.usage ?? null;
|
|
182
|
+
live.costUsd = frame.costUsd ?? null;
|
|
183
|
+
live.estimatedCostUsd = Number.isFinite(frame.estimatedCostUsd) ? frame.estimatedCostUsd : null; // display-only
|
|
184
|
+
dirty.meters = true;
|
|
185
|
+
break;
|
|
186
|
+
case 'ask-done':
|
|
187
|
+
finalizeDone(row, frame);
|
|
188
|
+
break;
|
|
189
|
+
case 'ask-error':
|
|
190
|
+
row.text = live.text;
|
|
191
|
+
row.status = 'error';
|
|
192
|
+
row.errorMessage = frame.message || 'unknown error';
|
|
193
|
+
live = null;
|
|
194
|
+
inFlight = null;
|
|
195
|
+
dirty.structure = true;
|
|
196
|
+
dirty.messages.add(row.id);
|
|
197
|
+
dirty.answer.add(row.id);
|
|
198
|
+
dirty.label = true;
|
|
199
|
+
dirty.meters = true;
|
|
200
|
+
break;
|
|
201
|
+
default:
|
|
202
|
+
break; // unknown ask-* job frame: its seq is consumed, its payload ignored
|
|
203
|
+
}
|
|
204
|
+
return { ok: true };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function applyOutOfTurn(frame) {
|
|
208
|
+
switch (frame.type) {
|
|
209
|
+
case 'ask-title':
|
|
210
|
+
thread.title = frame.title ?? null;
|
|
211
|
+
dirty.title = true;
|
|
212
|
+
return { ok: true };
|
|
213
|
+
case 'ask-message': {
|
|
214
|
+
const m = frame.message;
|
|
215
|
+
if (!m || typeof m.id !== 'string') return { dropped: 'no-live' };
|
|
216
|
+
upsertRow(m);
|
|
217
|
+
noteAttachmentBlocks(m.blocks);
|
|
218
|
+
dirty.messages.add(m.id);
|
|
219
|
+
return { ok: true };
|
|
220
|
+
}
|
|
221
|
+
case 'ask-run-status': {
|
|
222
|
+
if (typeof frame.runId !== 'string') return { dropped: 'no-live' };
|
|
223
|
+
const cur = links.get(frame.runId) || { pipelineId: null, cardId: null, status: null, phase: null };
|
|
224
|
+
links.set(frame.runId, {
|
|
225
|
+
pipelineId: frame.pipelineId ?? cur.pipelineId,
|
|
226
|
+
cardId: frame.cardId ?? cur.cardId,
|
|
227
|
+
status: frame.status ?? cur.status,
|
|
228
|
+
phase: frame.phase ?? cur.phase,
|
|
229
|
+
});
|
|
230
|
+
dirty.runLinks = true;
|
|
231
|
+
return { ok: true };
|
|
232
|
+
}
|
|
233
|
+
case 'ask-worktrees':
|
|
234
|
+
if (!Array.isArray(frame.worktrees)) return { dropped: 'no-live' };
|
|
235
|
+
worktrees = frame.worktrees.slice();
|
|
236
|
+
dirty.worktrees = true;
|
|
237
|
+
return { ok: true };
|
|
238
|
+
default:
|
|
239
|
+
return { dropped: 'no-live' };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return Object.freeze({
|
|
244
|
+
threadId,
|
|
245
|
+
load(snapshot) {
|
|
246
|
+
thread = { ...snapshot.thread };
|
|
247
|
+
rows = Array.isArray(snapshot.messages) ? snapshot.messages.slice() : [];
|
|
248
|
+
attachments = Array.isArray(snapshot.attachments) ? snapshot.attachments.slice() : [];
|
|
249
|
+
links.clear();
|
|
250
|
+
for (const l of Array.isArray(snapshot.runLinks) ? snapshot.runLinks : []) {
|
|
251
|
+
links.set(l.runId, { pipelineId: l.pipelineId ?? null, cardId: l.cardId ?? null, status: l.status ?? null, phase: l.phase ?? null });
|
|
252
|
+
}
|
|
253
|
+
worktrees = Array.isArray(snapshot.worktrees) ? snapshot.worktrees.slice() : []; // the fresh-thread load carries no key
|
|
254
|
+
live = null;
|
|
255
|
+
inFlight = snapshot.inFlight ?? null;
|
|
256
|
+
dirty = newDirty();
|
|
257
|
+
dirty.structure = true;
|
|
258
|
+
dirty.title = true;
|
|
259
|
+
dirty.meters = true;
|
|
260
|
+
dirty.runLinks = true;
|
|
261
|
+
dirty.label = true;
|
|
262
|
+
dirty.worktrees = true;
|
|
263
|
+
},
|
|
264
|
+
apply(frame) {
|
|
265
|
+
if (!frame || frame.threadId !== threadId) return { dropped: 'other-thread' };
|
|
266
|
+
if (typeof frame.seq === 'number') return applyJobFrame(frame);
|
|
267
|
+
return applyOutOfTurn(frame);
|
|
268
|
+
},
|
|
269
|
+
takeDirty() {
|
|
270
|
+
const d = dirty;
|
|
271
|
+
dirty = newDirty();
|
|
272
|
+
return d;
|
|
273
|
+
},
|
|
274
|
+
messages() { return rows; },
|
|
275
|
+
thread() { return thread; },
|
|
276
|
+
totals() {
|
|
277
|
+
const base = thread.totals && typeof thread.totals === 'object' ? thread.totals : {};
|
|
278
|
+
const liveAgents = live ? liveAgentCount() : 0;
|
|
279
|
+
return {
|
|
280
|
+
...base,
|
|
281
|
+
...(liveAgents ? { agents: (Number.isFinite(base.agents) ? base.agents : 0) + liveAgents } : {}),
|
|
282
|
+
live: live ? { usage: live.usage, costUsd: live.costUsd, estimatedCostUsd: live.estimatedCostUsd } : null,
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
inFlight() { return inFlight; },
|
|
286
|
+
live() { return live; },
|
|
287
|
+
runLinks() { return links; },
|
|
288
|
+
worktrees() { return worktrees; },
|
|
289
|
+
setWorktrees(list) { // the panel's heal (refreshWorktrees) feeds the same store the frames do
|
|
290
|
+
worktrees = Array.isArray(list) ? list.slice() : [];
|
|
291
|
+
dirty.worktrees = true;
|
|
292
|
+
},
|
|
293
|
+
attachmentsBytes() { return attachments.reduce((n, a) => n + (a && Number.isFinite(a.bytes) ? a.bytes : 0), 0); },
|
|
294
|
+
findCard(cardId) {
|
|
295
|
+
for (const r of rows) {
|
|
296
|
+
const b = (r && Array.isArray(r.blocks) ? r.blocks : []).find((x) => x && x.kind === 'card' && x.id === cardId);
|
|
297
|
+
if (b) return { message: r, block: b };
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
},
|
|
301
|
+
noteLocalUserMessage({ id, text, attachments: atts }) {
|
|
302
|
+
// The POST-side ask-message broadcast can land BEFORE the 202 resolves. That
|
|
303
|
+
// row is the persisted one (seq, store-minted attachment ids); an echo that
|
|
304
|
+
// replaced it would turn an image thumbnail back into a name pill until the
|
|
305
|
+
// next reload. Nothing the echo carries is newer than it, so keep it.
|
|
306
|
+
if (rowById(id)) { dirty.messages.add(id); return; }
|
|
307
|
+
const blocks = (Array.isArray(atts) ? atts : []).map((a) => ({ kind: 'attachment', id: a.id ?? null, name: a.name, bytes: a.bytes, attKind: a.attKind ?? 'text', mime: a.mime ?? null }));
|
|
308
|
+
upsertRow({
|
|
309
|
+
id, threadId, seq: undefined, role: 'user', text: String(text ?? ''),
|
|
310
|
+
blocks,
|
|
311
|
+
status: null, reason: null, model: null, effort: null, usage: null, costUsd: null, durationMs: null, createdAt: null,
|
|
312
|
+
});
|
|
313
|
+
noteAttachmentBlocks(blocks);
|
|
314
|
+
dirty.messages.add(id);
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
}
|