@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,213 @@
|
|
|
1
|
+
// src/core/diff-anchor.mjs
|
|
2
|
+
// The ONE anchor resolver behind POST /api/history/:key/:id/comments and the
|
|
3
|
+
// add_diff_comment MCP tool. It imports the SAME parser the browser renders with:
|
|
4
|
+
// ui/public/diff-view.mjs is DOM-free by design (its own header says so, and
|
|
5
|
+
// test/diff-view.test.mjs already drives it under node:test), so validation and
|
|
6
|
+
// rendering can never disagree — including MAX_FILE_SECTION_CODE_UNITS, where a row
|
|
7
|
+
// past the cap fails validation here exactly as it fails to render there.
|
|
8
|
+
//
|
|
9
|
+
// This is the ONE place in src/ that reaches into ui/public. ui/server.mjs already
|
|
10
|
+
// imports ./public/hljs-loader.mjs, so the direction is established; concentrating
|
|
11
|
+
// it here keeps it legible. The module is NOT moved: ui/public is the only directory
|
|
12
|
+
// express.static serves, so relocating it under src/ would need a new static route
|
|
13
|
+
// plus edits to app.js, index.html and two test files, for nothing.
|
|
14
|
+
//
|
|
15
|
+
// isProtectedBasename comes from ask/tools.mjs, where it is defined and exported.
|
|
16
|
+
// That direction is safe: tools.mjs has ZERO imports, so this can never close a
|
|
17
|
+
// cycle.
|
|
18
|
+
import { splitPatchSections, patchIndex, sectionKey, parseFileSection, MAX_FILE_SECTION_CODE_UNITS } from '../../ui/public/diff-view.mjs';
|
|
19
|
+
import { isProtectedBasename } from './ask/tools.mjs';
|
|
20
|
+
import { GUARDRAIL_PRESETS } from './guardrails.mjs';
|
|
21
|
+
|
|
22
|
+
/** The floor, exactly as tool-deps.mjs chooses it: never the run's own set. */
|
|
23
|
+
export const SECURE_PROTECTED_PATHS = Object.freeze([...GUARDRAIL_PRESETS.secure.protectedPaths]);
|
|
24
|
+
|
|
25
|
+
/** A refusal the caller returns verbatim (HTTP 400 / AskToolError text). */
|
|
26
|
+
export class AnchorError extends Error {
|
|
27
|
+
constructor(message) { super(message); this.name = 'AnchorError'; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The member projects a patch declares, in order. The test is byte-identical to
|
|
32
|
+
* splitPatchSections' own rather than the stricter MEMBER_HEADER_RE in
|
|
33
|
+
* ask/tools.mjs, so this can never disagree with the sections it is checked
|
|
34
|
+
* against. Scanned from the raw text, not from the sections, so a member whose
|
|
35
|
+
* patch contributed no section is still reported.
|
|
36
|
+
*/
|
|
37
|
+
export function patchMembers(text) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
40
|
+
if (!/^# \S/.test(line)) continue;
|
|
41
|
+
const key = line.slice(2).trim();
|
|
42
|
+
if (key && !out.includes(key)) out.push(key);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The section a (member, path) pair names in an ALREADY-PARSED index, or null. */
|
|
48
|
+
function pick(index, member, path) {
|
|
49
|
+
return index.get(sectionKey(member || null, path)) || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The section a (member, path) pair names, or null. One parse per call. */
|
|
53
|
+
function findSection(text, member, path) {
|
|
54
|
+
return pick(patchIndex(splitPatchSections(text)), member, path);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A section whose path this parser could NOT read. git C-quotes any name holding
|
|
59
|
+
* '"', '\\', a tab or a control byte — and, in patches persisted before
|
|
60
|
+
* core.quotePath=false was pinned (git-info.mjs:127), any non-ASCII name — as
|
|
61
|
+
* `"a/old\tsecret.pem"`. splitPatchSections keeps that literal, quotes and all
|
|
62
|
+
* (its DESCOPED note, diff-view.mjs:187-191: graceful for RENDERING), so
|
|
63
|
+
* isProtectedBasename tests the string `"a/old\tsecret.pem"` against `*.pem` and
|
|
64
|
+
* answers FALSE. get_run_diff has no such hole — splitUnifiedDiff un-C-quotes
|
|
65
|
+
* (ask/tools.mjs:23-41) — so this is the ONE place the two disagree, and it
|
|
66
|
+
* disagrees in the unsafe direction.
|
|
67
|
+
* It is reachable under an ordinary name: `rename from "old\tsecret.pem"` +
|
|
68
|
+
* `rename to plain.txt` yields a section keyed on the plain, enumerable
|
|
69
|
+
* `plain.txt` whose -/context rows are the .pem's content.
|
|
70
|
+
* A leading '"' can never begin a real path — git quotes precisely because '"'
|
|
71
|
+
* cannot appear raw — so this refuses nothing legitimate. It mirrors
|
|
72
|
+
* get_run_diff's `!s.path` drop: unreadable ⇒ dropped, never emitted.
|
|
73
|
+
*/
|
|
74
|
+
const unreadablePath = (p) => typeof p === 'string' && p.startsWith('"');
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve one anchor against the persisted patch.
|
|
78
|
+
*
|
|
79
|
+
* The member project is an EXPLICIT input and is never inferred — not even when
|
|
80
|
+
* exactly one member holds the path: the UI always knows which section it rendered
|
|
81
|
+
* (entry.project), and the model can call get_run_diff / list_diff_comments to find
|
|
82
|
+
* out. Guessing would make the same {path, side, line} mean different things on
|
|
83
|
+
* different runs.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} patchText the diff-patch.patch artifact
|
|
86
|
+
* @param {{project?: string|null, path: string, side: 'old'|'new', line: number, protectedPaths?: string[]}} input
|
|
87
|
+
* @returns {{project: string|null, path: string, oldPath: string|null, side: 'old'|'new', line: number, lineText: string}}
|
|
88
|
+
* @throws {AnchorError}
|
|
89
|
+
*/
|
|
90
|
+
export function resolveAnchor(patchText, {
|
|
91
|
+
project = null, path, side, line, protectedPaths = SECURE_PROTECTED_PATHS,
|
|
92
|
+
} = {}) {
|
|
93
|
+
const text = String(patchText ?? '');
|
|
94
|
+
const wantPath = typeof path === 'string' ? path.trim() : '';
|
|
95
|
+
if (!wantPath) throw new AnchorError('path is required');
|
|
96
|
+
if (side !== 'old' && side !== 'new') throw new AnchorError('side must be "old" (a removed line) or "new" (an added or context line)');
|
|
97
|
+
// NOT Math.trunc(Number(line)): that accepted 3.9 as line 3 while the message
|
|
98
|
+
// below promised an integer, silently anchoring a comment one row off. Number()
|
|
99
|
+
// still coerces the '2' a JSON-RPC client may send; isSafeInteger keeps 1e21 out.
|
|
100
|
+
const lineNo = Number(line);
|
|
101
|
+
if (!Number.isSafeInteger(lineNo) || lineNo < 1) throw new AnchorError('line must be a positive integer');
|
|
102
|
+
|
|
103
|
+
const members = patchMembers(text);
|
|
104
|
+
const member = typeof project === 'string' ? project.trim() : '';
|
|
105
|
+
if (members.length) {
|
|
106
|
+
if (!member) throw new AnchorError(`this is a workspace run — name the member project (one of: ${members.join(', ')})`);
|
|
107
|
+
if (!members.includes(member)) throw new AnchorError(`unknown member project "${member}" (members: ${members.join(', ')})`);
|
|
108
|
+
} else if (member) {
|
|
109
|
+
throw new AnchorError('this run has a single project — do not name a member project');
|
|
110
|
+
}
|
|
111
|
+
const scope = members.length ? member : null;
|
|
112
|
+
|
|
113
|
+
// ONE parse for the whole call: the not-found branch below probes every member,
|
|
114
|
+
// and findSection used to re-run splitPatchSections + patchIndex over the WHOLE
|
|
115
|
+
// patch for each of them (M+1 full parses per miss).
|
|
116
|
+
const index = patchIndex(splitPatchSections(text));
|
|
117
|
+
const guarded = (p) => !!p && isProtectedBasename(p, protectedPaths);
|
|
118
|
+
const blocked = (s) => unreadablePath(s.path) || unreadablePath(s.oldPath)
|
|
119
|
+
|| guarded(s.path) || guarded(s.oldPath);
|
|
120
|
+
|
|
121
|
+
const section = pick(index, scope, wantPath);
|
|
122
|
+
if (!section) {
|
|
123
|
+
// Name the members that DO hold the path so the caller can retry without a
|
|
124
|
+
// second round trip — but only members whose section this caller could
|
|
125
|
+
// actually READ. An unfiltered hint is an existence oracle: it answered
|
|
126
|
+
// "it is in: alpha-…" for a .env that get_run_diff never lists at all.
|
|
127
|
+
const holders = members.filter((m) => {
|
|
128
|
+
if (m === member) return false;
|
|
129
|
+
const s = pick(index, m, wantPath);
|
|
130
|
+
return !!s && !blocked(s);
|
|
131
|
+
});
|
|
132
|
+
throw new AnchorError(holders.length
|
|
133
|
+
? `"${wantPath}" is not in ${member}'s diff (it is in: ${holders.join(', ')})`
|
|
134
|
+
: `"${wantPath}" is not a file of this run's diff`);
|
|
135
|
+
}
|
|
136
|
+
// Fail closed exactly as get_run_diff does: BOTH sides, because -M makes a
|
|
137
|
+
// rename+edit ONE section under its NEW name while its -/context lines are the
|
|
138
|
+
// OLD file's content. If the model may not read a file's diff, its lines must not
|
|
139
|
+
// be smuggled into diff_comments as line_text either. splitPatchSections mirrors
|
|
140
|
+
// one side onto the other, so both fields are always populated for a section that
|
|
141
|
+
// reached patchIndex.
|
|
142
|
+
if (unreadablePath(section.path) || unreadablePath(section.oldPath)) {
|
|
143
|
+
throw new AnchorError(`"${wantPath}" has a git-quoted name this run's patch cannot resolve — it cannot be checked against the protected-path rules, so no comment is stored for it`);
|
|
144
|
+
}
|
|
145
|
+
if (guarded(section.path) || guarded(section.oldPath)) {
|
|
146
|
+
throw new AnchorError(`"${wantPath}" is a protected path — comments are not stored for credential files`);
|
|
147
|
+
}
|
|
148
|
+
const parsed = parseFileSection(section.raw);
|
|
149
|
+
if (parsed.binary || !parsed.hunks.length) throw new AnchorError(`"${wantPath}" has no textual diff to anchor to`);
|
|
150
|
+
// `lastNo` rides along in the same pass so the refusal can tell "this file has
|
|
151
|
+
// no such line" apart from "this resolver never read that far". get_run_diff
|
|
152
|
+
// applies NO cap (ask/tools.mjs pages the whole body), so a model that just read
|
|
153
|
+
// row 14,195 through it would otherwise be told the row does not exist.
|
|
154
|
+
let lastNo = 0;
|
|
155
|
+
for (const hunk of parsed.hunks) {
|
|
156
|
+
for (const row of hunk.lines) {
|
|
157
|
+
const no = side === 'old' ? row.oldNo : row.newNo;
|
|
158
|
+
if (no === lineNo) {
|
|
159
|
+
return { project: scope, path: wantPath, oldPath: section.oldPath ?? null, side, line: lineNo, lineText: row.text };
|
|
160
|
+
}
|
|
161
|
+
if (no != null && no > lastNo) lastNo = no;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
throw new AnchorError(parsed.truncated && lineNo > lastNo
|
|
165
|
+
? `"${wantPath}" has no ${side}-side line ${lineNo} in the first ${MAX_FILE_SECTION_CODE_UNITS} characters of its diff — that is as far as this file's section is read here (it stops at ${side}-side line ${lastNo}), so a line beyond the cap cannot be anchored even though get_run_diff can page to it`
|
|
166
|
+
: `"${wantPath}" has no ${side}-side line ${lineNo} in this run's diff`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The section keys of `patchText` the floor will always refuse, in the same
|
|
171
|
+
* `sectionKey(project, path)` form the browser indexes file rows by. Same parser,
|
|
172
|
+
* same preset, BOTH sides (a rename+edit is ONE section under its new name while
|
|
173
|
+
* its −/context lines are the old file's content) — so the UI can drop the '+'
|
|
174
|
+
* without ever seeing the glob list. `[]` for an absent or empty patch.
|
|
175
|
+
*/
|
|
176
|
+
export function protectedSectionKeys(patchText, protectedPaths = SECURE_PROTECTED_PATHS) {
|
|
177
|
+
const guarded = (p) => !!p && isProtectedBasename(p, protectedPaths);
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const s of splitPatchSections(String(patchText ?? ''))) {
|
|
180
|
+
if (!s.path) continue;
|
|
181
|
+
if (unreadablePath(s.path) || unreadablePath(s.oldPath) || guarded(s.path) || guarded(s.oldPath)) {
|
|
182
|
+
out.push(sectionKey(s.project || null, s.path));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The `radius` rows either side of an anchor, rendered with their diff sign so the
|
|
190
|
+
* model reads them the way get_run_diff serves them (parseFileSection strips the
|
|
191
|
+
* sign into `kind`, so it is re-added here). Produced by the SAME parser as
|
|
192
|
+
* everything else; `[]` whenever no readable section or no matching row exists
|
|
193
|
+
* (archived run, binary, a row past the cap), which is the caller's signal to omit
|
|
194
|
+
* context entirely rather than fake it.
|
|
195
|
+
* NOT redacted here — the caller redacts, exactly once, at its own boundary.
|
|
196
|
+
*/
|
|
197
|
+
export function hunkContext(patchText, { project = null, path, side, line } = {}, radius = 3) {
|
|
198
|
+
const text = String(patchText ?? '');
|
|
199
|
+
const members = patchMembers(text);
|
|
200
|
+
const section = findSection(text, members.length ? (project || '') : null, String(path ?? ''));
|
|
201
|
+
if (!section) return [];
|
|
202
|
+
const parsed = parseFileSection(section.raw);
|
|
203
|
+
const lineNo = Math.trunc(Number(line));
|
|
204
|
+
const sign = (kind) => (kind === 'add' ? '+' : kind === 'del' ? '-' : ' ');
|
|
205
|
+
for (const hunk of parsed.hunks) {
|
|
206
|
+
const at = hunk.lines.findIndex((r) => (side === 'old' ? r.oldNo : r.newNo) === lineNo);
|
|
207
|
+
if (at < 0) continue;
|
|
208
|
+
const from = Math.max(0, at - radius);
|
|
209
|
+
const to = Math.min(hunk.lines.length, at + radius + 1);
|
|
210
|
+
return hunk.lines.slice(from, to).map((r) => `${sign(r.kind)}${r.text}`);
|
|
211
|
+
}
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// src/core/diff-comments.mjs
|
|
2
|
+
// Internal, line-anchored comments on a run's persisted diff (diff_comments, v22).
|
|
3
|
+
// The ONE mutation module: the REST routes in ui/server.mjs and the Ask MCP tools
|
|
4
|
+
// (through src/core/ask/comment-deps.mjs) both write through here, so anchor
|
|
5
|
+
// validation, the protected-path floor, the body cap and the change notification
|
|
6
|
+
// can never drift apart. Synchronous DB via getDb()/prepare() — the
|
|
7
|
+
// src/core/ask/store.mjs conventions; ids are shape-checked before use.
|
|
8
|
+
//
|
|
9
|
+
// NOTE on the notification: listeners are PROCESS-LOCAL. ui/server.mjs subscribes
|
|
10
|
+
// at boot and turns each poke into a `diff-comments-changed` broadcast. The MCP
|
|
11
|
+
// stdio server is a separate OS process with its own module instance, so its writes
|
|
12
|
+
// fire only its own (empty) listener set — those reach the UI through the reducer
|
|
13
|
+
// hook in ask/events.mjs instead.
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import { getDb, prepare, tx } from './db.mjs';
|
|
16
|
+
import { resolveAnchor, AnchorError } from './diff-anchor.mjs';
|
|
17
|
+
|
|
18
|
+
export const DC_ID_RE = /^dc_[0-9a-f]{8}$/;
|
|
19
|
+
export const COMMENT_BODY_MAX = 4000;
|
|
20
|
+
export const COMMENT_AUTHORS = Object.freeze(['user', 'ask']);
|
|
21
|
+
|
|
22
|
+
const newCommentId = () => `dc_${randomBytes(4).toString('hex')}`;
|
|
23
|
+
const now = () => new Date().toISOString();
|
|
24
|
+
|
|
25
|
+
/** A refusal the caller returns verbatim (HTTP 400/409, AskToolError text). */
|
|
26
|
+
export class DiffCommentError extends Error {
|
|
27
|
+
constructor(message) { super(message); this.name = 'DiffCommentError'; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rowToComment(r) {
|
|
31
|
+
return {
|
|
32
|
+
id: r.id, storeKey: r.store_key, pipelineId: r.pipeline_id,
|
|
33
|
+
projectKey: r.project_key ?? null, path: r.path, oldPath: r.old_path ?? null,
|
|
34
|
+
side: r.side, line: r.line_no, lineText: r.line_text ?? '',
|
|
35
|
+
body: r.body, author: r.author,
|
|
36
|
+
resolved: !!r.resolved, resolvedAt: r.resolved_at ?? null,
|
|
37
|
+
sentRunId: r.sent_run_id ?? null, createdAt: r.created_at,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── change notification ─────────────────────────────────────────────────────
|
|
42
|
+
const listeners = new Set();
|
|
43
|
+
|
|
44
|
+
/** Subscribe to "this run's comments changed". Returns an unsubscribe function. */
|
|
45
|
+
export function onDiffCommentsChanged(fn) {
|
|
46
|
+
listeners.add(fn);
|
|
47
|
+
return () => listeners.delete(fn);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** ids only, no payload: idempotent for the receiver, no ordering concerns. */
|
|
51
|
+
function notify(storeKey, pipelineId) {
|
|
52
|
+
for (const fn of listeners) {
|
|
53
|
+
try { fn({ storeKey, pipelineId }); } catch { /* a broken sink never breaks a write */ }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── reads ───────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/** One comment by id, or null. Shape-checks the id: it reaches a SQL parameter only. */
|
|
60
|
+
export function getDiffComment(id) {
|
|
61
|
+
if (typeof id !== 'string' || !DC_ID_RE.test(id)) return null;
|
|
62
|
+
getDb();
|
|
63
|
+
const r = prepare('SELECT * FROM diff_comments WHERE id = ?').get(id);
|
|
64
|
+
return r ? rowToComment(r) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A run's comments, ordered by path, then line, then CREATION ORDER (the brief:
|
|
69
|
+
* multiple comments on one line stack in creation order).
|
|
70
|
+
*
|
|
71
|
+
* The final key is `rowid`, NOT `created_at, id`: created_at is millisecond ISO
|
|
72
|
+
* text, so two comments written in the same tick tie, and `id` is random hex — the
|
|
73
|
+
* pair would order those two randomly. rowid is SQLite's monotonic insertion
|
|
74
|
+
* counter and the table is not WITHOUT ROWID (D17).
|
|
75
|
+
*
|
|
76
|
+
* The SQL is assembled from a CLOSED set of fragments (3 statuses x 2 path
|
|
77
|
+
* options = 6 texts), so the prepare() cache stays bounded; no caller value is ever
|
|
78
|
+
* interpolated.
|
|
79
|
+
* @param {{status?: 'all'|'unresolved'|'resolved', path?: string|null}} [opts]
|
|
80
|
+
*/
|
|
81
|
+
export function listDiffComments(storeKey, pipelineId, { status = 'all', path = null } = {}) {
|
|
82
|
+
getDb();
|
|
83
|
+
const where = ['store_key = ?', 'pipeline_id = ?'];
|
|
84
|
+
const vals = [String(storeKey ?? ''), String(pipelineId ?? '')];
|
|
85
|
+
if (status === 'unresolved') where.push('resolved = 0');
|
|
86
|
+
else if (status === 'resolved') where.push('resolved = 1');
|
|
87
|
+
if (path) { where.push('path = ?'); vals.push(String(path)); }
|
|
88
|
+
return prepare(`SELECT * FROM diff_comments WHERE ${where.join(' AND ')}
|
|
89
|
+
ORDER BY path, line_no, rowid`).all(...vals).map(rowToComment);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Unresolved counts keyed "<storeKey>/<pipelineId>", newest-commented first and
|
|
93
|
+
* hard-capped: the endpoint fans out to every open tab on every poke, and an
|
|
94
|
+
* unbounded row-per-commented-run response is the one part of it that grows with
|
|
95
|
+
* history. 5000 is a backstop, not a paging story — /api/history is itself
|
|
96
|
+
* unbounded, so anything the cap drops belongs to a card far below the fold. */
|
|
97
|
+
export function unresolvedCounts() {
|
|
98
|
+
getDb();
|
|
99
|
+
const out = {};
|
|
100
|
+
for (const r of prepare(`SELECT store_key, pipeline_id, count(*) AS n, max(rowid) AS last
|
|
101
|
+
FROM diff_comments WHERE resolved = 0
|
|
102
|
+
GROUP BY store_key, pipeline_id
|
|
103
|
+
ORDER BY last DESC LIMIT 5000`).all()) {
|
|
104
|
+
out[`${r.store_key}/${r.pipeline_id}`] = r.n;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── writes ──────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Create one comment. `patchText` is the run's diff-patch.patch as READ BY THE
|
|
113
|
+
* CALLER (the server through readRunArtifactText, the MCP bundle through
|
|
114
|
+
* deps.readDiffPatch) — the anchor, and the line_text snapshot, come from it and
|
|
115
|
+
* never from the caller's input.
|
|
116
|
+
* @throws {DiffCommentError} — including for an AnchorError, re-wrapped so callers
|
|
117
|
+
* have one error type to map onto 400 / tool-error.
|
|
118
|
+
*/
|
|
119
|
+
export function addDiffComment({
|
|
120
|
+
storeKey, pipelineId, patchText, project = null, path, side, line, body, author,
|
|
121
|
+
} = {}) {
|
|
122
|
+
if (patchText == null || String(patchText) === '') {
|
|
123
|
+
throw new DiffCommentError('this run has no stored diff — comments cannot be created on it');
|
|
124
|
+
}
|
|
125
|
+
if (!COMMENT_AUTHORS.includes(author)) throw new DiffCommentError('author must be "user" or "ask"');
|
|
126
|
+
const text = typeof body === 'string' ? body.trim() : '';
|
|
127
|
+
if (!text) throw new DiffCommentError('body is required');
|
|
128
|
+
if (text.length > COMMENT_BODY_MAX) throw new DiffCommentError(`body exceeds ${COMMENT_BODY_MAX} characters`);
|
|
129
|
+
|
|
130
|
+
let anchor;
|
|
131
|
+
try {
|
|
132
|
+
anchor = resolveAnchor(patchText, { project, path, side, line });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
throw err instanceof AnchorError ? new DiffCommentError(err.message) : err;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const id = newCommentId();
|
|
138
|
+
const ts = now();
|
|
139
|
+
getDb();
|
|
140
|
+
prepare(`INSERT INTO diff_comments
|
|
141
|
+
(id, store_key, pipeline_id, project_key, path, old_path, side, line_no, line_text,
|
|
142
|
+
body, author, resolved, resolved_at, sent_run_id, source, external_url, created_at)
|
|
143
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, NULL, NULL, ?)`)
|
|
144
|
+
.run(id, String(storeKey), String(pipelineId), anchor.project, anchor.path, anchor.oldPath,
|
|
145
|
+
anchor.side, anchor.line, anchor.lineText, text, author, ts);
|
|
146
|
+
notify(String(storeKey), String(pipelineId));
|
|
147
|
+
return getDiffComment(id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Toggle. Returns the updated comment, or null when the id is unknown. */
|
|
151
|
+
export function setDiffCommentResolved(id, resolved = true) {
|
|
152
|
+
const before = getDiffComment(id);
|
|
153
|
+
if (!before) return null;
|
|
154
|
+
const on = resolved !== false;
|
|
155
|
+
getDb();
|
|
156
|
+
prepare('UPDATE diff_comments SET resolved = ?, resolved_at = ? WHERE id = ?')
|
|
157
|
+
.run(on ? 1 : 0, on ? now() : null, id);
|
|
158
|
+
notify(before.storeKey, before.pipelineId);
|
|
159
|
+
return getDiffComment(id);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Hard delete. true when a row went away. */
|
|
163
|
+
export function deleteDiffComment(id) {
|
|
164
|
+
const before = getDiffComment(id);
|
|
165
|
+
if (!before) return false;
|
|
166
|
+
getDb();
|
|
167
|
+
const info = prepare('DELETE FROM diff_comments WHERE id = ?').run(id);
|
|
168
|
+
if (!info.changes) return false;
|
|
169
|
+
notify(before.storeKey, before.pipelineId);
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Stamp `sent_run_id` on every id given. NEVER resolves anything (the brief).
|
|
175
|
+
* SCOPED to the launched run's store: a comment on project A's run can never be
|
|
176
|
+
* marked "sent to" a run in project B (nothing ever un-stamps it, so a wrong
|
|
177
|
+
* marker is permanent). The card's target is NOT authoritative here — the Start
|
|
178
|
+
* form lets the user re-pick the project/workspace — so the scope comes from the
|
|
179
|
+
* pipelines row that actually exists by now.
|
|
180
|
+
* Notifies per DISTINCT comment run, and OUTSIDE the tx: the poke names the run
|
|
181
|
+
* whose Diff tab shows the marker (its own), not the run it was sent to, and a
|
|
182
|
+
* listener must never run while the write lock is held (tx() is not re-entrant).
|
|
183
|
+
* @returns {number} rows stamped
|
|
184
|
+
*/
|
|
185
|
+
export function stampSentRunId(commentIds, pipelineId) {
|
|
186
|
+
const ids = (Array.isArray(commentIds) ? commentIds : []).filter((s) => typeof s === 'string' && DC_ID_RE.test(s));
|
|
187
|
+
const pid = typeof pipelineId === 'string' ? pipelineId : '';
|
|
188
|
+
if (!ids.length || !pid) return 0;
|
|
189
|
+
getDb();
|
|
190
|
+
const { n, storeKey: sk, targets } = tx(() => {
|
|
191
|
+
const run = getDb().prepare('SELECT project_key, workspace_key, target FROM pipelines WHERE id = ?').get(pid);
|
|
192
|
+
if (!run) return { n: 0, storeKey: null, targets: [] };
|
|
193
|
+
// SAME formula as artifacts.mjs (`target === 'workspace' && workspace_key`): with
|
|
194
|
+
// `||` a project row that merely carries a workspace_key was stamped against a
|
|
195
|
+
// store it does not live in and the comment was never marked (review of PR #376).
|
|
196
|
+
const storeKey = (run.target === 'workspace' && run.workspace_key) ? `workspaces/${run.workspace_key}` : run.project_key;
|
|
197
|
+
const stmt = getDb().prepare('UPDATE diff_comments SET sent_run_id = ? WHERE id = ? AND store_key = ?');
|
|
198
|
+
const read = getDb().prepare('SELECT pipeline_id FROM diff_comments WHERE id = ?');
|
|
199
|
+
// A stamped row matched `store_key = storeKey`, so its store is already known;
|
|
200
|
+
// only its own run id varies, and that is what the poke has to name.
|
|
201
|
+
const runs = new Set();
|
|
202
|
+
let hits = 0;
|
|
203
|
+
for (const id of ids) {
|
|
204
|
+
if (!stmt.run(pid, id, storeKey).changes) continue;
|
|
205
|
+
hits += 1;
|
|
206
|
+
const r = read.get(id);
|
|
207
|
+
if (r) runs.add(r.pipeline_id);
|
|
208
|
+
}
|
|
209
|
+
return { n: hits, storeKey, targets: [...runs] };
|
|
210
|
+
});
|
|
211
|
+
for (const runId of targets) notify(sk, runId);
|
|
212
|
+
return n;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Delete every comment of a run. NO transaction of its own — the CALLER owns it
|
|
217
|
+
* (db.mjs tx() is not re-entrant and throws on nesting, and archivePipeline calls
|
|
218
|
+
* this from inside its own tx). Used only by the archive path.
|
|
219
|
+
*/
|
|
220
|
+
export function deleteCommentsForRun(pipelineId) {
|
|
221
|
+
getDb();
|
|
222
|
+
return getDb().prepare('DELETE FROM diff_comments WHERE pipeline_id = ?').run(String(pipelineId)).changes;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── proposal → launch hand-off (D11) ────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Record the comment ids a proposal card is meant to address. Called from the
|
|
229
|
+
* turn's proposal hook, which is the only place that sees propose_run's raw input:
|
|
230
|
+
* commentIds deliberately never enter the card block (its key set is pinned in
|
|
231
|
+
* test/ask-proposal.test.mjs) nor CARD_PATCH_KEYS. Unknown ids are dropped
|
|
232
|
+
* silently — the model may cite a comment the user deleted in the meantime, and
|
|
233
|
+
* that must not sink the proposal.
|
|
234
|
+
*/
|
|
235
|
+
export function setPendingCardComments(cardId, commentIds) {
|
|
236
|
+
const card = typeof cardId === 'string' ? cardId.trim() : '';
|
|
237
|
+
const ids = (Array.isArray(commentIds) ? commentIds : []).filter((s) => typeof s === 'string' && DC_ID_RE.test(s));
|
|
238
|
+
if (!card || !ids.length) return 0;
|
|
239
|
+
getDb();
|
|
240
|
+
return tx(() => {
|
|
241
|
+
const stmt = getDb().prepare('INSERT OR IGNORE INTO ask_card_comments (card_id, comment_id, created_at) VALUES (?, ?, ?)');
|
|
242
|
+
const known = getDb().prepare('SELECT 1 AS ok FROM diff_comments WHERE id = ?');
|
|
243
|
+
const ts = now();
|
|
244
|
+
let n = 0;
|
|
245
|
+
for (const id of ids) {
|
|
246
|
+
if (!known.get(id)) continue;
|
|
247
|
+
n += stmt.run(card, id, ts).changes;
|
|
248
|
+
}
|
|
249
|
+
return n;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The pending ids of a card, WITHOUT consuming them. Split from the delete so the
|
|
255
|
+
* caller can write them onto ask_run_links first and only then drop them: the
|
|
256
|
+
* consumer's catch merely logs, so a combined take-then-write loses the ids for good
|
|
257
|
+
* if the write throws.
|
|
258
|
+
*/
|
|
259
|
+
export function peekPendingCardComments(cardId) {
|
|
260
|
+
const card = typeof cardId === 'string' ? cardId.trim() : '';
|
|
261
|
+
if (!card) return [];
|
|
262
|
+
getDb();
|
|
263
|
+
return getDb().prepare('SELECT comment_id FROM ask_card_comments WHERE card_id = ? ORDER BY rowid')
|
|
264
|
+
.all(card).map((r) => r.comment_id);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Drop a card's pending ids once they are safely on the link row. */
|
|
268
|
+
export function clearPendingCardComments(cardId) {
|
|
269
|
+
const card = typeof cardId === 'string' ? cardId.trim() : '';
|
|
270
|
+
if (!card) return 0;
|
|
271
|
+
getDb();
|
|
272
|
+
return getDb().prepare('DELETE FROM ask_card_comments WHERE card_id = ?').run(card).changes;
|
|
273
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/core/engine-select.mjs
|
|
2
|
+
// One engine remains. The only decision left is refusing a run/resume that was
|
|
3
|
+
// pinned to the retired v1 engine.
|
|
4
|
+
|
|
5
|
+
import { createOrchestrator } from './orchestrator.mjs';
|
|
6
|
+
import { V1_RUN_RETIRED } from './db.mjs';
|
|
7
|
+
|
|
8
|
+
export class EngineRetiredError extends Error {
|
|
9
|
+
constructor() { super(V1_RUN_RETIRED); this.code = 'ENGINE_RETIRED'; this.status = 409; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build the orchestrator this run needs. Still ASYNC (every call site awaits it)
|
|
14
|
+
* and still accepts `opts.template` as a routing HINT that is never forwarded as
|
|
15
|
+
* an orchestrator option.
|
|
16
|
+
* @param {object} opts createOrchestrator options (+ optional opts.resume / opts.template)
|
|
17
|
+
* @returns {Promise<object>} a graph orchestrator, `.engine === 'graph'`
|
|
18
|
+
* @throws {EngineRetiredError} when the resume point is not a graph point
|
|
19
|
+
*/
|
|
20
|
+
export async function createOrchestratorFor(opts = {}) {
|
|
21
|
+
const { template, ...rest } = opts;
|
|
22
|
+
const rpVersion = rest?.resume?.resumePoint?.version;
|
|
23
|
+
// A resume must run on the engine that FROZE the point. Only `version: 2` is
|
|
24
|
+
// resumable now; `undefined` means "no point at all" (a fresh run) and falls
|
|
25
|
+
// through, but a point that carries any OTHER version — including an
|
|
26
|
+
// unversioned `null` blob the v1 engine wrote — is refused rather than
|
|
27
|
+
// silently re-planned on the graph engine.
|
|
28
|
+
if (rpVersion !== undefined && Number(rpVersion) !== 2) throw new EngineRetiredError();
|
|
29
|
+
const orch = createOrchestrator(rest);
|
|
30
|
+
orch.engine = 'graph'; // the only answer there is; kept observable for callers and tests
|
|
31
|
+
return orch;
|
|
32
|
+
}
|