karajan-code 2.34.0 → 3.1.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 +120 -38
- package/bin/kj-rag-mcp.js +75 -0
- package/docs/README.es.md +5 -5
- package/package.json +22 -17
- package/packages/hu-board/package.json +5 -2
- package/packages/hu-board/public/app.js +81 -4025
- package/packages/hu-board/public/index.html +20 -0
- package/packages/hu-board/public/utils/api.js +139 -0
- package/packages/hu-board/public/utils/board-view.js +453 -0
- package/packages/hu-board/public/utils/command-launcher.js +213 -0
- package/packages/hu-board/public/utils/config-editor.js +181 -0
- package/packages/hu-board/public/utils/dashboard-view.js +103 -0
- package/packages/hu-board/public/utils/formatters.js +232 -0
- package/packages/hu-board/public/utils/graph-view.js +216 -0
- package/packages/hu-board/public/utils/hu-actions.js +224 -0
- package/packages/hu-board/public/utils/init-listeners.js +180 -0
- package/packages/hu-board/public/utils/log-panel.js +222 -0
- package/packages/hu-board/public/utils/modals.js +290 -0
- package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
- package/packages/hu-board/public/utils/preflight-view.js +89 -0
- package/packages/hu-board/public/utils/project-actions.js +143 -0
- package/packages/hu-board/public/utils/project-picker-view.js +139 -0
- package/packages/hu-board/public/utils/run-launcher.js +169 -0
- package/packages/hu-board/public/utils/server-push.js +252 -0
- package/packages/hu-board/public/utils/sessions-view.js +199 -0
- package/packages/hu-board/public/utils/story-detail-view.js +324 -0
- package/packages/hu-board/public/utils/story-edit-form.js +271 -0
- package/packages/hu-board/src/config-yaml.js +14 -0
- package/packages/hu-board/src/db.js +15 -2
- package/packages/hu-board/src/routes/api.js +27 -9
- package/scripts/audit-test-diet.mjs +151 -0
- package/src/audit/deterministic-summary.js +20 -0
- package/src/audit/harness-scorecard.js +52 -3
- package/src/audit/harness-section.js +6 -2
- package/src/audit/injection-findings.js +105 -0
- package/src/checks/hardware.js +150 -0
- package/src/checks/node.js +9 -10
- package/src/checks/runner.js +10 -0
- package/src/cli/register-meta.js +48 -2
- package/src/cli/register-plan.js +10 -1
- package/src/commands/clean.js +62 -0
- package/src/commands/doctor.js +2 -0
- package/src/commands/init.js +41 -1
- package/src/commands/plan/generate.js +9 -3
- package/src/commands/rag-mcp.js +18 -0
- package/src/commands/sync.js +30 -2
- package/src/commands/telemetry.js +70 -0
- package/src/config/defaults.js +9 -1
- package/src/config/schema.js +2 -0
- package/src/orchestrator/config-init.js +1 -0
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
- package/src/orchestrator/stages/coder-stage.js +11 -2
- package/src/orchestrator/stages/reviewer-stage.js +11 -0
- package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
- package/src/orchestrator/stages/tool-judge-stage.js +53 -0
- package/src/prompts/tool-correctness-judge.js +31 -0
- package/src/review/git-stash-helper.js +104 -0
- package/src/review/tdd-discipline.js +90 -0
- package/src/roles/audit-role.js +9 -1
- package/src/roles/tool-correctness-judge-role.js +78 -0
- package/src/session/mutators.js +4 -0
- package/src/sonar/scanner.js +51 -16
- package/src/sync/apply-canvas-patch.js +55 -0
- package/src/utils/paths.js +12 -0
- package/src/utils/quota-simulator.js +56 -0
- package/src/utils/repo-cleaner.js +79 -0
- package/src/utils/telemetry.js +34 -12
- package/src/utils/tool-call-extractor.js +56 -0
- package/src/utils/vector-store-orphans.js +89 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// KJC-TSK-0501 step 7/8 — Project picker view (default Board landing).
|
|
2
|
+
//
|
|
3
|
+
// Classic script (no exports). Loaded by index.html before app.js so
|
|
4
|
+
// the function declaration hoists into the script-level lexical
|
|
5
|
+
// environment and app.js can call renderProjectPicker() from its
|
|
6
|
+
// router.
|
|
7
|
+
//
|
|
8
|
+
// Globals consumed from earlier scripts / app.js:
|
|
9
|
+
// - api() (utils/api.js)
|
|
10
|
+
// - esc() (app.js)
|
|
11
|
+
// - renderEmptyState() (app.js)
|
|
12
|
+
// - resolveProjectMeta() (app.js)
|
|
13
|
+
// - projectNameCache (mutable, app.js)
|
|
14
|
+
// - humaniseProjectName() (app.js)
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* "Pick a project" view shown by the Board tab when nothing is
|
|
18
|
+
* selected. Lists every project that has at least one HU with the
|
|
19
|
+
* pending / running / done / failed counts — clicking takes the
|
|
20
|
+
* user into that project's kanban (`#board/<slug>`).
|
|
21
|
+
*
|
|
22
|
+
* Why this instead of an "All projects" merged kanban: HU short ids
|
|
23
|
+
* collide across projects (every project has a `*-001`), the column
|
|
24
|
+
* counts become meaningless, and "missing test contract" warnings
|
|
25
|
+
* apply to whatever-project so the user can't tell what to act on.
|
|
26
|
+
* Forcing a project pick before the kanban is the cheapest way to
|
|
27
|
+
* keep every other UI invariant honest.
|
|
28
|
+
*/
|
|
29
|
+
async function renderProjectPicker() {
|
|
30
|
+
const app = document.getElementById('app');
|
|
31
|
+
const projects = await api('/api/projects');
|
|
32
|
+
// Pre-resolve human names so the rendered list matches what the
|
|
33
|
+
// header / dropdown show elsewhere.
|
|
34
|
+
await Promise.all(projects.map((p) => resolveProjectMeta(p.id)));
|
|
35
|
+
|
|
36
|
+
if (projects.length === 0) {
|
|
37
|
+
app.innerHTML = renderEmptyState(
|
|
38
|
+
'No projects yet',
|
|
39
|
+
'Run kj plan or click ⚡ in the header to generate one. The board will pick it up automatically.'
|
|
40
|
+
);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Per-project status counts come from /api/projects/:id/stories.
|
|
45
|
+
// One round-trip per project; cheap on a local SQLite-backed API.
|
|
46
|
+
const allStories = await Promise.all(
|
|
47
|
+
projects.map((p) => api(`/api/projects/${encodeURIComponent(p.id)}/stories`).then((s) => ({ id: p.id, stories: s })))
|
|
48
|
+
);
|
|
49
|
+
const byProject = new Map(allStories.map((entry) => [entry.id, entry.stories]));
|
|
50
|
+
|
|
51
|
+
function bucket(stories) {
|
|
52
|
+
const c = { pending: 0, running: 0, done: 0, failed: 0 };
|
|
53
|
+
for (const s of stories) {
|
|
54
|
+
if (['pending', 'certified', 'needs_context', 'blocked'].includes(s.status)) c.pending += 1;
|
|
55
|
+
else if (['coding', 'reviewing'].includes(s.status)) c.running += 1;
|
|
56
|
+
else if (s.status === 'done') c.done += 1;
|
|
57
|
+
else if (s.status === 'failed') c.failed += 1;
|
|
58
|
+
}
|
|
59
|
+
return c;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Sort: most recent activity first. Falls back to alphabetic when
|
|
63
|
+
// last_activity is missing (older projects synced pre-#480).
|
|
64
|
+
const sorted = [...projects].sort((a, b) => {
|
|
65
|
+
const la = a.last_activity || '';
|
|
66
|
+
const lb = b.last_activity || '';
|
|
67
|
+
if (la && lb && la !== lb) return lb.localeCompare(la);
|
|
68
|
+
return (a.name || a.id).localeCompare(b.name || b.id);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
app.innerHTML = `
|
|
72
|
+
<div class="section-header">
|
|
73
|
+
<span class="section-header__title">Story Board</span>
|
|
74
|
+
<span class="section-header__count">${sorted.length} project${sorted.length === 1 ? '' : 's'}</span>
|
|
75
|
+
</div>
|
|
76
|
+
<p style="padding:8px 4px 16px;color:var(--text-muted);font-size:0.9rem">
|
|
77
|
+
Pick a project to see its kanban. Use the project selector in the header to switch later.
|
|
78
|
+
</p>
|
|
79
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(320px, 1fr));gap:14px">
|
|
80
|
+
${sorted.map((p) => {
|
|
81
|
+
const counts = bucket(byProject.get(p.id) || []);
|
|
82
|
+
const total = counts.pending + counts.running + counts.done + counts.failed;
|
|
83
|
+
const name = projectNameCache[p.id] || p.name || humaniseProjectName(p.id);
|
|
84
|
+
// div en lugar de <button> para poder anidar el botón de
|
|
85
|
+
// delete (HTML no permite buttons anidados). role+tabindex
|
|
86
|
+
// mantienen la accesibilidad equivalente.
|
|
87
|
+
return `
|
|
88
|
+
<div role="button" tabindex="0" class="project-picker__card" data-project-id="${esc(p.id)}"
|
|
89
|
+
style="position:relative;text-align:left;display:flex;flex-direction:column;gap:8px;padding:14px 16px;
|
|
90
|
+
background:var(--bg-secondary);border:1px solid var(--border);
|
|
91
|
+
border-radius:var(--radius-sm);cursor:pointer;color:var(--text);
|
|
92
|
+
transition:border-color 120ms">
|
|
93
|
+
<button class="project-card__delete" title="Borrar proyecto (cascade)"
|
|
94
|
+
data-project-id="${esc(p.id)}" data-project-name="${esc(name)}">🗑️</button>
|
|
95
|
+
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:8px;padding-right:32px">
|
|
96
|
+
<strong style="font-size:0.95rem">${esc(name)}</strong>
|
|
97
|
+
<span style="font-size:0.75rem;color:var(--text-muted)">${total} HU${total === 1 ? '' : 's'}</span>
|
|
98
|
+
</div>
|
|
99
|
+
<div style="display:flex;flex-wrap:wrap;gap:6px;font-size:0.78rem">
|
|
100
|
+
${counts.pending > 0 ? `<span title="Pending" style="padding:2px 8px;border-radius:var(--radius-sm);background:var(--bg-primary)">⏳ ${counts.pending}</span>` : ''}
|
|
101
|
+
${counts.running > 0 ? `<span title="Running" style="padding:2px 8px;border-radius:var(--radius-sm);background:rgba(234,179,8,0.18);color:#facc15">⚙ ${counts.running}</span>` : ''}
|
|
102
|
+
${counts.done > 0 ? `<span title="Done" style="padding:2px 8px;border-radius:var(--radius-sm);background:rgba(74,222,128,0.18);color:#4ade80">✓ ${counts.done}</span>` : ''}
|
|
103
|
+
${counts.failed > 0 ? `<span title="Failed" style="padding:2px 8px;border-radius:var(--radius-sm);background:rgba(248,113,113,0.18);color:#f87171">✗ ${counts.failed}</span>` : ''}
|
|
104
|
+
${total === 0 ? `<span style="color:var(--text-muted)">empty</span>` : ''}
|
|
105
|
+
</div>
|
|
106
|
+
<div style="font-family:var(--font-mono, monospace);font-size:0.7rem;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(p.id)}">
|
|
107
|
+
${esc(p.id)}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
`;
|
|
111
|
+
}).join('')}
|
|
112
|
+
</div>
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
app.querySelectorAll('.project-picker__card').forEach((btn) => {
|
|
116
|
+
const enter = () => {
|
|
117
|
+
const id = btn.dataset.projectId;
|
|
118
|
+
// Use the existing route so back/forward work and the dropdown
|
|
119
|
+
// syncs via handleRoute().
|
|
120
|
+
window.location.hash = `board/${encodeURIComponent(id)}`;
|
|
121
|
+
};
|
|
122
|
+
btn.addEventListener('click', (e) => {
|
|
123
|
+
// Click sobre el botón delete anidado — el handler global ya
|
|
124
|
+
// hace stopPropagation + preventDefault, pero por defensa
|
|
125
|
+
// ignoramos aquí cualquier click que venga de dentro de él.
|
|
126
|
+
if (e.target.closest('.project-card__delete')) return;
|
|
127
|
+
enter();
|
|
128
|
+
});
|
|
129
|
+
btn.addEventListener('keydown', (e) => {
|
|
130
|
+
if ((e.key === 'Enter' || e.key === ' ') && !e.target.closest('.project-card__delete')) {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
enter();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
// Tiny hover affordance — no CSS file edit needed.
|
|
136
|
+
btn.addEventListener('mouseenter', () => { btn.style.borderColor = 'var(--color-green)'; });
|
|
137
|
+
btn.addEventListener('mouseleave', () => { btn.style.borderColor = 'var(--border)'; });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// KJC-TSK-0501 step 8f/8 — Preflight gate + run launcher.
|
|
2
|
+
//
|
|
3
|
+
// Classic script (no exports). Loaded by index.html before app.js so the
|
|
4
|
+
// top-level `function`/`let` declarations are hoisted into the shared
|
|
5
|
+
// realm and the inline handlers in app.js (and elsewhere) resolve
|
|
6
|
+
// `runProject` / `confirmRunWithPreflight` via that hoisting.
|
|
7
|
+
//
|
|
8
|
+
// Globals consumed from earlier scripts / app.js:
|
|
9
|
+
// - esc() (utils/formatters.js)
|
|
10
|
+
// - ensureDialog() (app.js)
|
|
11
|
+
// - showError() (utils/modals.js)
|
|
12
|
+
// - renderBoard() (app.js)
|
|
13
|
+
// - openLogViewer() (utils/log-panel.js)
|
|
14
|
+
// - lastLaunchedPlanId (mutable in app.js — assignment crosses
|
|
15
|
+
// scripts transparently)
|
|
16
|
+
// - lastOpenedLog (mutable in app.js — same)
|
|
17
|
+
|
|
18
|
+
// ---- Preflight panel + safety modal ----
|
|
19
|
+
//
|
|
20
|
+
// Goal: when a non-technical user opens a project, they should see at
|
|
21
|
+
// a glance whether the environment is ready (git initialised, remote
|
|
22
|
+
// configured, agents installed, Node version, etc.). Each missing
|
|
23
|
+
// item is rendered with a plain-Spanish "consequence" so the user
|
|
24
|
+
// understands what's at stake.
|
|
25
|
+
//
|
|
26
|
+
// The panel doubles as the gate for ▶ Run plan / ▶ Run HU: if any
|
|
27
|
+
// blocker (status:fail with blocking:true) is present, we refuse to
|
|
28
|
+
// launch. If only warnings are present, we open a confirm modal
|
|
29
|
+
// listing them and require an explicit "Lanzar de todos modos".
|
|
30
|
+
|
|
31
|
+
let preflightCache = null; // memoise per-render so the modal can re-use what the panel already fetched
|
|
32
|
+
|
|
33
|
+
async function fetchPreflight(projectId) {
|
|
34
|
+
try {
|
|
35
|
+
const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/preflight`);
|
|
36
|
+
if (!r.ok) return null;
|
|
37
|
+
return await r.json();
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function preflightStatusIcon(status) {
|
|
42
|
+
if (status === 'ok') return '✓';
|
|
43
|
+
if (status === 'warn') return '⚠';
|
|
44
|
+
if (status === 'fail') return '✗';
|
|
45
|
+
return 'ℹ';
|
|
46
|
+
}
|
|
47
|
+
function preflightStatusColor(status) {
|
|
48
|
+
if (status === 'ok') return 'var(--color-green)';
|
|
49
|
+
if (status === 'warn') return 'var(--color-yellow,#eab308)';
|
|
50
|
+
if (status === 'fail') return 'var(--color-red,#ef4444)';
|
|
51
|
+
return 'var(--text-muted)';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pre-run safety modal. Returns true if the user wants to proceed,
|
|
56
|
+
* false to cancel. Call BEFORE invoking the run endpoint.
|
|
57
|
+
*
|
|
58
|
+
* Behaviour:
|
|
59
|
+
* - All green → returns true immediately (no modal).
|
|
60
|
+
* - Blockers present → modal explains them in plain Spanish; only
|
|
61
|
+
* "Cancelar" button (no proceed-anyway) because blockers are
|
|
62
|
+
* known to break the run.
|
|
63
|
+
* - Only warnings → modal lists them with their consequences and
|
|
64
|
+
* offers "Cancelar" or "Lanzar de todos modos" (red button so
|
|
65
|
+
* the user notices they're accepting risk).
|
|
66
|
+
*/
|
|
67
|
+
async function confirmRunWithPreflight(projectId) {
|
|
68
|
+
const data = preflightCache || await fetchPreflight(projectId);
|
|
69
|
+
if (!data) return true; // can't fetch → don't block the user
|
|
70
|
+
const blockers = data.blockers || [];
|
|
71
|
+
const warnings = data.warnings || [];
|
|
72
|
+
if (blockers.length === 0 && warnings.length === 0) return true;
|
|
73
|
+
|
|
74
|
+
return await new Promise((resolve) => {
|
|
75
|
+
const dlg = document.getElementById('app-dialog') || ensureDialog();
|
|
76
|
+
const isBlocked = blockers.length > 0;
|
|
77
|
+
const headerColor = isBlocked ? 'var(--color-red,#ef4444)' : 'var(--color-yellow,#eab308)';
|
|
78
|
+
const allItems = [...blockers, ...warnings];
|
|
79
|
+
dlg.innerHTML = `
|
|
80
|
+
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;display:flex;align-items:center;gap:10px;background:${headerColor};color:#000;">
|
|
81
|
+
<span style="font-size:1.2rem;">${isBlocked ? '✗' : '⚠'}</span>
|
|
82
|
+
<span>${isBlocked ? 'No se puede lanzar — falta algo crítico' : 'Atención antes de lanzar'}</span>
|
|
83
|
+
</div>
|
|
84
|
+
<div style="padding:14px 18px;max-height:60vh;overflow:auto;">
|
|
85
|
+
<p style="margin:0 0 10px;font-size:0.9rem;color:var(--text);">
|
|
86
|
+
${isBlocked
|
|
87
|
+
? 'Se detectaron problemas bloqueantes que impedirán que Karajan funcione correctamente:'
|
|
88
|
+
: 'Se detectaron avisos. Karajan puede ejecutarse, pero con limitaciones:'}
|
|
89
|
+
</p>
|
|
90
|
+
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:8px;">
|
|
91
|
+
${allItems.map((c) => `
|
|
92
|
+
<li style="padding:8px 10px;background:var(--bg-primary);border-left:3px solid ${preflightStatusColor(c.status)};border-radius:var(--radius-sm);">
|
|
93
|
+
<div style="font-weight:600;color:var(--text);font-size:0.85rem;">
|
|
94
|
+
${preflightStatusIcon(c.status)} ${esc(c.label)} — <span style="color:var(--text-muted);font-weight:400;">${esc(c.detail || '')}</span>
|
|
95
|
+
</div>
|
|
96
|
+
${c.consequence ? `<div style="font-size:0.8rem;color:var(--text);margin-top:4px;">${esc(c.consequence)}</div>` : ''}
|
|
97
|
+
</li>
|
|
98
|
+
`).join('')}
|
|
99
|
+
</ul>
|
|
100
|
+
</div>
|
|
101
|
+
<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">
|
|
102
|
+
<button id="preflight-cancel" style="padding:8px 16px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);cursor:pointer;">Cancelar</button>
|
|
103
|
+
${isBlocked ? '' : `<button id="preflight-proceed" style="padding:8px 16px;background:var(--color-red,#ef4444);border:none;color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600;">Lanzar de todos modos</button>`}
|
|
104
|
+
</div>
|
|
105
|
+
`;
|
|
106
|
+
if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
|
|
107
|
+
const cancel = dlg.querySelector('#preflight-cancel');
|
|
108
|
+
const proceed = dlg.querySelector('#preflight-proceed');
|
|
109
|
+
const close = (val) => { try { dlg.close(); } catch { /* ignore */ } resolve(val); };
|
|
110
|
+
cancel?.addEventListener('click', () => close(false), { once: true });
|
|
111
|
+
proceed?.addEventListener('click', () => close(true), { once: true });
|
|
112
|
+
dlg.addEventListener('close', () => resolve(false), { once: true });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function runProject(projectId) {
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}/run`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
121
|
+
body: JSON.stringify({}),
|
|
122
|
+
});
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
if (res.status === 404) {
|
|
125
|
+
await showError(
|
|
126
|
+
'The board server does not recognise the "run" endpoint.\n\n'
|
|
127
|
+
+ 'Most likely cause: the board process is running a pre-v2.7.5 build. '
|
|
128
|
+
+ 'Restart it with:\n\n'
|
|
129
|
+
+ ' kj board stop && kj board start',
|
|
130
|
+
{ title: 'Board out of date' }
|
|
131
|
+
);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
|
|
135
|
+
await showError(msg, { title: 'Could not launch run' });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const body = await res.json();
|
|
139
|
+
// Best-effort: a chokidar tick may still be in flight, so fetch
|
|
140
|
+
// once explicitly to surface the "running" state without waiting
|
|
141
|
+
// for the watcher debounce.
|
|
142
|
+
await fetch('/api/sync', { method: 'POST' }).catch(() => {});
|
|
143
|
+
await renderBoard();
|
|
144
|
+
|
|
145
|
+
// Remember the first successful planId so the section header can
|
|
146
|
+
// offer a "View log" button without the user having to re-launch.
|
|
147
|
+
const firstOk = (body.results || []).find((r) => r.ok);
|
|
148
|
+
if (firstOk && firstOk.planId) {
|
|
149
|
+
lastLaunchedPlanId = firstOk.planId;
|
|
150
|
+
lastOpenedLog = {
|
|
151
|
+
id: firstOk.planId,
|
|
152
|
+
label: 'Run log',
|
|
153
|
+
tailUrl: (offset) => `/api/plans/${encodeURIComponent(firstOk.planId)}/log?offset=${offset}`,
|
|
154
|
+
};
|
|
155
|
+
// Open the log viewer straight away — the user's mental model is
|
|
156
|
+
// "I clicked Run, I want to see what's happening", not "I clicked
|
|
157
|
+
// Run, now where do I look".
|
|
158
|
+
openLogViewer(firstOk.planId);
|
|
159
|
+
} else {
|
|
160
|
+
await showError(
|
|
161
|
+
`Pipeline launched for ${body.launched}/${body.total} plan(s) but `
|
|
162
|
+
+ 'no planId came back, so there\'s no log to show.',
|
|
163
|
+
{ title: 'Run started' }
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
} catch (err) {
|
|
167
|
+
await showError(err.message, { title: 'Could not launch run' });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// KJC-TSK-0501 step 8g/8 — Server-push updates (SSE → smart DOM patch).
|
|
2
|
+
//
|
|
3
|
+
// Classic script (no exports). Loaded by index.html before app.js so
|
|
4
|
+
// the top-level `function` / `let` declarations are hoisted into the
|
|
5
|
+
// shared realm and `subscribeToServerEvents` is callable from app.js's
|
|
6
|
+
// DOMContentLoaded handler.
|
|
7
|
+
//
|
|
8
|
+
// Globals consumed from earlier scripts / app.js:
|
|
9
|
+
// - showPromptModal() (utils/modals.js)
|
|
10
|
+
// - closePromptModalIfMatches() (utils/modals.js)
|
|
11
|
+
// - render() (app.js)
|
|
12
|
+
// - api() (utils/api.js)
|
|
13
|
+
// - renderStoryCard() (utils/board-view.js)
|
|
14
|
+
// - cssEscape() (utils/formatters.js)
|
|
15
|
+
// - currentView (mutable in app.js — read here)
|
|
16
|
+
// - selectedProject (mutable in app.js — read here)
|
|
17
|
+
|
|
18
|
+
// ---- Server-push updates ----
|
|
19
|
+
//
|
|
20
|
+
// The board gets a one-way stream of events from the server via
|
|
21
|
+
// Server-Sent Events; each event hints at what changed (plan file,
|
|
22
|
+
// session, batch). On receipt we refresh the current view in a way
|
|
23
|
+
// that preserves scroll position and any open modal. Full-page
|
|
24
|
+
// re-render is no longer on the table — clicking around doesn't
|
|
25
|
+
// reset your scroll anymore.
|
|
26
|
+
//
|
|
27
|
+
// EventSource auto-reconnects on drop, so we don't manage lifecycle
|
|
28
|
+
// ourselves. The status line below the header flashes briefly on
|
|
29
|
+
// each update so the user can see the data is live.
|
|
30
|
+
|
|
31
|
+
let sseSource = null;
|
|
32
|
+
let sseRefreshTimer = null;
|
|
33
|
+
|
|
34
|
+
function subscribeToServerEvents() {
|
|
35
|
+
if (typeof EventSource === 'undefined') return; // ancient browser
|
|
36
|
+
if (sseSource) sseSource.close();
|
|
37
|
+
sseSource = new EventSource('/api/events');
|
|
38
|
+
sseSource.addEventListener('message', (e) => {
|
|
39
|
+
// Try to parse the event as a typed payload. Prompt events drive
|
|
40
|
+
// the prompt modal; everything else just nudges a re-render.
|
|
41
|
+
let payload;
|
|
42
|
+
try { payload = JSON.parse(e.data); } catch { payload = null; }
|
|
43
|
+
|
|
44
|
+
if (payload?.type === 'prompt' && payload.promptId) {
|
|
45
|
+
// A non-interactive runner is asking for input — pop the modal
|
|
46
|
+
// immediately. fetch the full prompt body from the API so we
|
|
47
|
+
// don't depend on the SSE event carrying every field.
|
|
48
|
+
fetch(`/api/prompts`).then(r => r.ok ? r.json() : []).then((list) => {
|
|
49
|
+
const match = list.find((p) => p.promptId === payload.promptId);
|
|
50
|
+
if (match) showPromptModal(match);
|
|
51
|
+
}).catch(() => {});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (payload?.type === 'prompt-resolved' && payload.promptId) {
|
|
55
|
+
// Runner read its answer file — close the modal if it's still open.
|
|
56
|
+
closePromptModalIfMatches(payload.promptId);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Default: data changed somewhere. Try the targeted DOM patch
|
|
61
|
+
// first (no parpadeo, preserves scroll + hover state) and only
|
|
62
|
+
// fall back to a full re-render when the patch can't apply
|
|
63
|
+
// (different view, missing markers, etc.). This is the fix for
|
|
64
|
+
// the "todo el board parpadea cada vez que algo cambia" UX bug.
|
|
65
|
+
if (sseRefreshTimer) clearTimeout(sseRefreshTimer);
|
|
66
|
+
sseRefreshTimer = setTimeout(() => smartRefresh(payload), 150);
|
|
67
|
+
});
|
|
68
|
+
sseSource.addEventListener('error', () => {
|
|
69
|
+
// EventSource handles reconnection. Nothing to do; browsers retry
|
|
70
|
+
// the `retry:` value we sent on open.
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// On boot, also fetch any prompts that were already pending before
|
|
74
|
+
// this tab connected (we missed their SSE add-event).
|
|
75
|
+
fetch('/api/prompts').then((r) => r.ok ? r.json() : []).then((list) => {
|
|
76
|
+
for (const p of list) showPromptModal(p);
|
|
77
|
+
}).catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Refresh the current SPA view without touching scroll position or
|
|
82
|
+
* any modal state. The heavy lifting already lives in renderBoard /
|
|
83
|
+
* renderGraph / renderSessions; this just re-calls them while the
|
|
84
|
+
* scroll offset is pinned, so the visual effect is "the card
|
|
85
|
+
* status updated in place".
|
|
86
|
+
* @param {string} _rawEvent unused for now — we could branch on
|
|
87
|
+
* event type later to patch only the touched card, but a full
|
|
88
|
+
* re-render with scroll preservation is already invisible to
|
|
89
|
+
* the user and a lot simpler to reason about.
|
|
90
|
+
*/
|
|
91
|
+
async function refreshCurrentView(_rawEvent) {
|
|
92
|
+
const app = document.getElementById('app');
|
|
93
|
+
const scrollTop = app?.scrollTop ?? 0;
|
|
94
|
+
const scrollLeft = app?.scrollLeft ?? 0;
|
|
95
|
+
// Modal is in a separate DOM branch (#modal-backdrop) so render()
|
|
96
|
+
// never touches it — we don't need to save its state.
|
|
97
|
+
try {
|
|
98
|
+
await render();
|
|
99
|
+
} finally {
|
|
100
|
+
const fresh = document.getElementById('app');
|
|
101
|
+
if (fresh) {
|
|
102
|
+
fresh.scrollTop = scrollTop;
|
|
103
|
+
fresh.scrollLeft = scrollLeft;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Smart refresh: pick the cheapest possible DOM update for the
|
|
110
|
+
* incoming SSE event. Today the server sends coarse-grained events
|
|
111
|
+
* (`{type: 'plan'|'batch'|'session'|'prompt'|...}`), so we route by
|
|
112
|
+
* type:
|
|
113
|
+
*
|
|
114
|
+
* plan / batch on the board view → patchBoardIncremental()
|
|
115
|
+
* anything else → refreshCurrentView() (full re-render)
|
|
116
|
+
*
|
|
117
|
+
* The patch path preserves DOM nodes (no innerHTML rewrite, no
|
|
118
|
+
* scroll reset, no hover/focus loss) — this is what kills the
|
|
119
|
+
* "parpadeo" the user reported.
|
|
120
|
+
*
|
|
121
|
+
* @param {object|null} payload The parsed SSE payload, or null when
|
|
122
|
+
* the event wasn't valid JSON. Coarse types still work (we just
|
|
123
|
+
* degrade to a full re-render).
|
|
124
|
+
*/
|
|
125
|
+
async function smartRefresh(payload) {
|
|
126
|
+
const onBoard = currentView === 'board' && Boolean(selectedProject);
|
|
127
|
+
const isStoryRelated = payload && ['plan', 'batch'].includes(payload.type);
|
|
128
|
+
if (onBoard && isStoryRelated) {
|
|
129
|
+
const ok = await patchBoardIncremental();
|
|
130
|
+
if (ok) return;
|
|
131
|
+
}
|
|
132
|
+
await refreshCurrentView();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Targeted DOM patch for the Kanban board. Re-fetches the project's
|
|
137
|
+
* stories, diffs them against the cards already rendered, and only
|
|
138
|
+
* touches the affected nodes:
|
|
139
|
+
*
|
|
140
|
+
* - status changed → move the card to the new column with a
|
|
141
|
+
* subtle animation (no DOM recreation, listeners survive)
|
|
142
|
+
* - new story → render + insert into the right column
|
|
143
|
+
* - story removed → fade + remove
|
|
144
|
+
* - column counts and the running badge are updated in place
|
|
145
|
+
*
|
|
146
|
+
* Returns false if the board's DOM markers aren't present (different
|
|
147
|
+
* view, error state, etc.) so smartRefresh can fall back to a full
|
|
148
|
+
* re-render.
|
|
149
|
+
*
|
|
150
|
+
* @returns {Promise<boolean>}
|
|
151
|
+
*/
|
|
152
|
+
async function patchBoardIncremental() {
|
|
153
|
+
const kanban = document.querySelector('.kanban');
|
|
154
|
+
if (!kanban) return false; // not on the board view
|
|
155
|
+
const columns = {};
|
|
156
|
+
for (const col of kanban.querySelectorAll('.kanban__column')) {
|
|
157
|
+
const cls = col.dataset.column;
|
|
158
|
+
if (cls) columns[cls] = col;
|
|
159
|
+
}
|
|
160
|
+
if (!columns.pending || !columns.running) return false;
|
|
161
|
+
|
|
162
|
+
let stories;
|
|
163
|
+
try {
|
|
164
|
+
stories = await api(`/api/projects/${encodeURIComponent(selectedProject)}/stories`);
|
|
165
|
+
} catch { return false; }
|
|
166
|
+
|
|
167
|
+
// Re-derive the column → status mapping (kept in lockstep with
|
|
168
|
+
// renderBoard's `columns` object — change there, change here).
|
|
169
|
+
const statusToColumn = (status) => {
|
|
170
|
+
if (['pending', 'certified', 'needs_context', 'blocked'].includes(status)) return 'pending';
|
|
171
|
+
if (['coding', 'reviewing'].includes(status)) return 'running';
|
|
172
|
+
if (status === 'done') return 'done';
|
|
173
|
+
if (status === 'failed') return 'failed';
|
|
174
|
+
return 'pending'; // safe default
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
for (const story of stories) {
|
|
179
|
+
seen.add(story.id);
|
|
180
|
+
const targetCol = columns[statusToColumn(story.status)];
|
|
181
|
+
if (!targetCol) continue;
|
|
182
|
+
const existing = kanban.querySelector(`.story-card[data-story-id="${cssEscape(story.id)}"]`);
|
|
183
|
+
if (!existing) {
|
|
184
|
+
// New story — append to its column.
|
|
185
|
+
const wrapper = document.createElement('div');
|
|
186
|
+
wrapper.innerHTML = renderStoryCard(story).trim();
|
|
187
|
+
const node = wrapper.firstElementChild;
|
|
188
|
+
if (node) {
|
|
189
|
+
node.style.opacity = '0';
|
|
190
|
+
targetCol.appendChild(node);
|
|
191
|
+
requestAnimationFrame(() => { node.style.transition = 'opacity 200ms'; node.style.opacity = '1'; });
|
|
192
|
+
}
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const previousStatus = existing.dataset.status;
|
|
196
|
+
if (previousStatus !== story.status) {
|
|
197
|
+
// Status changed — move the node + replace its inner content
|
|
198
|
+
// (counters, time-ago, status pill) without recreating the
|
|
199
|
+
// outer node so click handlers + transitions survive.
|
|
200
|
+
const wrapper = document.createElement('div');
|
|
201
|
+
wrapper.innerHTML = renderStoryCard(story).trim();
|
|
202
|
+
const fresh = wrapper.firstElementChild;
|
|
203
|
+
if (fresh) {
|
|
204
|
+
existing.innerHTML = fresh.innerHTML;
|
|
205
|
+
existing.dataset.status = story.status;
|
|
206
|
+
existing.setAttribute('onclick', fresh.getAttribute('onclick') || '');
|
|
207
|
+
if (existing.parentElement !== targetCol) {
|
|
208
|
+
existing.style.transition = 'opacity 150ms';
|
|
209
|
+
existing.style.opacity = '0.3';
|
|
210
|
+
setTimeout(() => {
|
|
211
|
+
targetCol.appendChild(existing);
|
|
212
|
+
requestAnimationFrame(() => { existing.style.opacity = '1'; });
|
|
213
|
+
}, 150);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
// Same status — just refresh the inner block (counters / time
|
|
218
|
+
// ago / antipatterns may have changed) without moving.
|
|
219
|
+
const wrapper = document.createElement('div');
|
|
220
|
+
wrapper.innerHTML = renderStoryCard(story).trim();
|
|
221
|
+
const fresh = wrapper.firstElementChild;
|
|
222
|
+
if (fresh && fresh.innerHTML !== existing.innerHTML) {
|
|
223
|
+
existing.innerHTML = fresh.innerHTML;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Cards that no longer exist on the server → remove with a fade.
|
|
228
|
+
for (const node of kanban.querySelectorAll('.story-card')) {
|
|
229
|
+
if (!seen.has(node.dataset.storyId)) {
|
|
230
|
+
node.style.transition = 'opacity 150ms';
|
|
231
|
+
node.style.opacity = '0';
|
|
232
|
+
setTimeout(() => node.remove(), 160);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Recount each column + update the "N running" badge in the header.
|
|
236
|
+
for (const [cls, col] of Object.entries(columns)) {
|
|
237
|
+
const counter = col.querySelector('[data-column-count]');
|
|
238
|
+
if (counter) counter.textContent = String(col.querySelectorAll('.story-card').length);
|
|
239
|
+
col.style.opacity = col.querySelectorAll('.story-card').length === 0 ? '0.55' : '1';
|
|
240
|
+
}
|
|
241
|
+
const runningCount = columns.running.querySelectorAll('.story-card').length;
|
|
242
|
+
const runningBadge = document.querySelector('.section-header__badge');
|
|
243
|
+
if (runningBadge) {
|
|
244
|
+
if (runningCount > 0) {
|
|
245
|
+
runningBadge.textContent = `⚙ ${runningCount} running…`;
|
|
246
|
+
runningBadge.style.display = '';
|
|
247
|
+
} else {
|
|
248
|
+
runningBadge.style.display = 'none';
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return true;
|
|
252
|
+
}
|