transitions-refine 0.3.11 → 0.3.13
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/demo.html +2 -2
- package/package.json +1 -1
- package/server/live-refine-loop.mjs +473 -0
- package/server/relay.mjs +25 -3
package/demo.html
CHANGED
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
|
|
52
52
|
/* transitions.dev — menu dropdown */
|
|
53
53
|
--dropdown-open-dur: 250ms; --dropdown-close-dur: 150ms;
|
|
54
|
-
--dropdown-pre-scale: 0.
|
|
54
|
+
--dropdown-pre-scale: 0.97; --dropdown-closing-scale: 0.99;
|
|
55
55
|
--dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
56
56
|
/* transitions.dev — icon swap */
|
|
57
57
|
--icon-swap-dur: 200ms; --icon-swap-blur: 2px; --icon-swap-start-scale: 0.25; --icon-swap-ease: ease-in-out;
|
|
@@ -199,7 +199,7 @@
|
|
|
199
199
|
05-menu-dropdown.md / 06-modal.md). Demo-only — excluded from the build. */
|
|
200
200
|
:root {
|
|
201
201
|
--dropdown-open-dur: 250ms; --dropdown-close-dur: 150ms;
|
|
202
|
-
--dropdown-pre-scale: 0.
|
|
202
|
+
--dropdown-pre-scale: 0.97; --dropdown-closing-scale: 0.99;
|
|
203
203
|
--dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
|
204
204
|
--modal-open-dur: 250ms; --modal-close-dur: 150ms;
|
|
205
205
|
--modal-scale: 0.96; --modal-scale-close: 0.96;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "transitions-refine",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
4
4
|
"description": "Live, agent-driven Refine panel for CSS/Motion transitions — injects a timeline + Refine UI and runs transitions.dev suggestions via your coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// Live Refine agent loop — polls GET /jobs/next and answers LLM jobs per refine-live skill.
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { SMOOTH_OUT } from "./motion-tokens.mjs";
|
|
6
|
+
|
|
7
|
+
const RELAY = process.env.REFINE_RELAY_URL || "http://localhost:7331";
|
|
8
|
+
const WORKSPACE = process.env.REFINE_WORKSPACE || join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
const LOG = join(WORKSPACE, ".refine-live.log");
|
|
10
|
+
|
|
11
|
+
let jobsHandled = 0;
|
|
12
|
+
let errors = 0;
|
|
13
|
+
|
|
14
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
15
|
+
|
|
16
|
+
function log(msg) {
|
|
17
|
+
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
18
|
+
try {
|
|
19
|
+
writeFileSync(LOG, line, { flag: "a" });
|
|
20
|
+
} catch {}
|
|
21
|
+
console.log(msg);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function relayFetch(path, opts = {}) {
|
|
25
|
+
const res = await fetch(`${RELAY}${path}`, opts);
|
|
26
|
+
return res;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function post(path, body) {
|
|
30
|
+
await relayFetch(path, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "Content-Type": "application/json" },
|
|
33
|
+
body: JSON.stringify(body),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── usage inference ──────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function inferUsage(label = "", selector = "") {
|
|
40
|
+
const s = `${label} ${selector}`.toLowerCase();
|
|
41
|
+
if (/modal|dialog/.test(s)) return "modal";
|
|
42
|
+
if (/dropdown|menu|popover/.test(s)) return "dropdown";
|
|
43
|
+
if (/tooltip/.test(s)) return "tooltip";
|
|
44
|
+
if (/badge|notification/.test(s)) return "badge";
|
|
45
|
+
if (/accordion|collapse/.test(s)) return "accordion";
|
|
46
|
+
if (/panel|drawer|sidebar/.test(s)) return "panel";
|
|
47
|
+
if (/toast/.test(s)) return "toast";
|
|
48
|
+
if (/tab/.test(s)) return "tabs";
|
|
49
|
+
if (/shake|error|invalid/.test(s)) return "shake";
|
|
50
|
+
if (/skeleton|shimmer/.test(s)) return "skeleton";
|
|
51
|
+
if (/icon/.test(s)) return "icon";
|
|
52
|
+
if (/resize|width|height|size/.test(s)) return "resize";
|
|
53
|
+
if (/color|background|theme/.test(s)) return "color";
|
|
54
|
+
if (/opacity|fade/.test(s)) return "fade";
|
|
55
|
+
if (/transform|slide|translate/.test(s)) return "slide";
|
|
56
|
+
return "generic";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function targetDurationMs(usage, property) {
|
|
60
|
+
const p = (property || "").toLowerCase();
|
|
61
|
+
switch (usage) {
|
|
62
|
+
case "modal":
|
|
63
|
+
case "dropdown":
|
|
64
|
+
return 250; // open fast; close handled separately in scan
|
|
65
|
+
case "tooltip":
|
|
66
|
+
return p === "opacity" ? 150 : 150;
|
|
67
|
+
case "badge":
|
|
68
|
+
return 500;
|
|
69
|
+
case "panel":
|
|
70
|
+
return 400;
|
|
71
|
+
case "toast":
|
|
72
|
+
return 350;
|
|
73
|
+
case "tabs":
|
|
74
|
+
return 250;
|
|
75
|
+
case "shake":
|
|
76
|
+
return 80;
|
|
77
|
+
case "skeleton":
|
|
78
|
+
return 400;
|
|
79
|
+
case "icon":
|
|
80
|
+
return 250;
|
|
81
|
+
case "resize":
|
|
82
|
+
return 250;
|
|
83
|
+
case "accordion":
|
|
84
|
+
return 400;
|
|
85
|
+
default:
|
|
86
|
+
return 250;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function shouldNudgeEasing(easing) {
|
|
91
|
+
const n = String(easing || "").replace(/\s+/g, "").toLowerCase();
|
|
92
|
+
if (!n) return false;
|
|
93
|
+
const tokens = new Set([
|
|
94
|
+
SMOOTH_OUT.replace(/\s+/g, "").toLowerCase(),
|
|
95
|
+
"ease-in-out",
|
|
96
|
+
"ease-out",
|
|
97
|
+
"linear",
|
|
98
|
+
"cubic-bezier(0.34,1.36,0.64,1)",
|
|
99
|
+
"cubic-bezier(0.34,3.85,0.64,1)",
|
|
100
|
+
]);
|
|
101
|
+
if (tokens.has(n)) return false;
|
|
102
|
+
return n === "ease" || n === "ease-in" || n.startsWith("cubic-bezier") || n.startsWith("linear(");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function handleSmallRefine(request) {
|
|
106
|
+
const usage = inferUsage(request.label, request.selector);
|
|
107
|
+
const timings = request.timings || [];
|
|
108
|
+
const suggestions = [];
|
|
109
|
+
|
|
110
|
+
for (const t of timings) {
|
|
111
|
+
const prop = t.property || "all";
|
|
112
|
+
const target = targetDurationMs(usage, prop);
|
|
113
|
+
if (Number.isFinite(t.durationMs) && Math.abs(t.durationMs - target) > 10) {
|
|
114
|
+
suggestions.push({
|
|
115
|
+
id: `${prop}-duration`,
|
|
116
|
+
kind: "duration",
|
|
117
|
+
property: prop,
|
|
118
|
+
title: `Duration → ${target}ms`,
|
|
119
|
+
from: `${t.durationMs}ms`,
|
|
120
|
+
to: `${target}ms`,
|
|
121
|
+
patch: { property: prop, durationMs: target },
|
|
122
|
+
reason: `${usage} motion reads best at ${target}ms for ${prop}.`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (shouldNudgeEasing(t.easing)) {
|
|
126
|
+
suggestions.push({
|
|
127
|
+
id: `${prop}-easing`,
|
|
128
|
+
kind: "easing",
|
|
129
|
+
property: prop,
|
|
130
|
+
title: "Easing → Smooth ease out",
|
|
131
|
+
from: t.easing,
|
|
132
|
+
to: SMOOTH_OUT,
|
|
133
|
+
patch: { property: prop, easing: SMOOTH_OUT },
|
|
134
|
+
reason: "Generic curve → transitions.dev smooth ease-out.",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
suggestions,
|
|
141
|
+
summary: suggestions.length
|
|
142
|
+
? `Nudged ${suggestions.length} value(s) toward motion tokens.`
|
|
143
|
+
: "Already aligned to motion tokens.",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── replace recipes ──────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
const RECIPES = [
|
|
150
|
+
{ id: "card-resize", file: "01-card-resize.md", title: "Card resize", match: /resize|width|height|size/ },
|
|
151
|
+
{ id: "menu-dropdown", file: "05-menu-dropdown.md", title: "Menu dropdown", match: /dropdown|menu|popover/ },
|
|
152
|
+
{ id: "modal", file: "06-modal.md", title: "Modal open/close", match: /modal|dialog/ },
|
|
153
|
+
{ id: "panel-reveal", file: "07-panel-reveal.md", title: "Panel reveal", match: /panel|drawer|sidebar/ },
|
|
154
|
+
{ id: "tooltip", file: "17-tooltip.md", title: "Tooltip", match: /tooltip/ },
|
|
155
|
+
{ id: "notification-badge", file: "03-notification-badge.md", title: "Notification badge", match: /badge|notification/ },
|
|
156
|
+
{ id: "accordion", file: "21-accordion.md", title: "Accordion", match: /accordion|collapse/ },
|
|
157
|
+
{ id: "icon-swap", file: "09-icon-swap.md", title: "Icon swap", match: /icon/ },
|
|
158
|
+
{ id: "tabs-sliding", file: "16-tabs-sliding.md", title: "Tabs sliding", match: /tab/ },
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
function pickRecipe(request) {
|
|
162
|
+
const s = `${request.label || ""} ${request.selector || ""}`.toLowerCase();
|
|
163
|
+
for (const r of RECIPES) {
|
|
164
|
+
if (r.match.test(s)) return r;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function handleReplaceRefine(request) {
|
|
170
|
+
const recipe = pickRecipe(request);
|
|
171
|
+
if (!recipe) {
|
|
172
|
+
return { suggestions: [], summary: "No matching transitions.dev recipe for this usage." };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const prop = request.timings?.[0]?.property || "all";
|
|
176
|
+
const phases = Array.isArray(request.phases) ? request.phases.filter((p) => p?.phase) : [];
|
|
177
|
+
const hasOpenClose = phases.some((p) => p.phase === "open") && phases.some((p) => p.phase === "close");
|
|
178
|
+
|
|
179
|
+
const basePatch = {
|
|
180
|
+
property: prop,
|
|
181
|
+
durationMs: 250,
|
|
182
|
+
easing: SMOOTH_OUT,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const suggestion = {
|
|
186
|
+
id: `replace-${recipe.id}`,
|
|
187
|
+
kind: "replace",
|
|
188
|
+
property: prop,
|
|
189
|
+
title: `Replace with ${recipe.title}`,
|
|
190
|
+
from: "hand-rolled transition",
|
|
191
|
+
to: `transitions.dev · ${recipe.title}`,
|
|
192
|
+
patch: basePatch,
|
|
193
|
+
reference: `transitions-dev/${recipe.file}`,
|
|
194
|
+
reason: `Usage matches ${recipe.title}. Paste ${recipe.file} for full structure.`,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (hasOpenClose) {
|
|
198
|
+
suggestion.patches = [
|
|
199
|
+
{ ...basePatch, durationMs: 250, phase: "open" },
|
|
200
|
+
{ ...basePatch, durationMs: 150, phase: "close", easing: SMOOTH_OUT },
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
suggestions: [suggestion],
|
|
206
|
+
summary: `Suggested ${recipe.title} recipe.`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── scan: parse cssRules ─────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
function parseTransitionDecl(text) {
|
|
213
|
+
const out = [];
|
|
214
|
+
const re = /([\w-]+)\s+(\d+(?:\.\d+)?)(ms|s)(?:\s+([\w().,-]+))?(?:\s+(\d+(?:\.\d+)?)(ms|s))?/gi;
|
|
215
|
+
let m;
|
|
216
|
+
while ((m = re.exec(text)) !== null) {
|
|
217
|
+
let ms = parseFloat(m[2]);
|
|
218
|
+
if (m[3] === "s") ms *= 1000;
|
|
219
|
+
let delay = 0;
|
|
220
|
+
if (m[5]) {
|
|
221
|
+
delay = parseFloat(m[5]);
|
|
222
|
+
if (m[6] === "s") delay *= 1000;
|
|
223
|
+
}
|
|
224
|
+
out.push({
|
|
225
|
+
property: m[1],
|
|
226
|
+
durationMs: Math.round(ms),
|
|
227
|
+
delayMs: Math.round(delay),
|
|
228
|
+
easing: m[4] || "ease",
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function slug(s) {
|
|
235
|
+
return String(s || "item")
|
|
236
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
237
|
+
.replace(/^-|-$/g, "")
|
|
238
|
+
.toLowerCase() || "item";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function detectPhase(selector) {
|
|
242
|
+
const s = String(selector || "").toLowerCase();
|
|
243
|
+
if (/closing|close|hide|exit|leave/.test(s)) return "close";
|
|
244
|
+
if (/open|show|enter|visible|is-active/.test(s)) return "open";
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function handleScan(request) {
|
|
249
|
+
const raw = request.raw || [];
|
|
250
|
+
const groupMap = new Map();
|
|
251
|
+
|
|
252
|
+
for (const entry of raw) {
|
|
253
|
+
const sel = entry.selector || entry.label || "unknown";
|
|
254
|
+
const baseKey = sel.split(/[\s>+~]/).pop()?.replace(/^[.#]/, "") || slug(entry.label);
|
|
255
|
+
const groupId = slug(baseKey.split("-")[0] || baseKey);
|
|
256
|
+
const groupLabel = groupId.charAt(0).toUpperCase() + groupId.slice(1);
|
|
257
|
+
|
|
258
|
+
if (!groupMap.has(groupId)) {
|
|
259
|
+
groupMap.set(groupId, {
|
|
260
|
+
id: groupId,
|
|
261
|
+
label: groupLabel,
|
|
262
|
+
component: request.url || null,
|
|
263
|
+
phases: new Map(),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
const group = groupMap.get(groupId);
|
|
267
|
+
const rules = entry.cssRules || [];
|
|
268
|
+
const currentTimings = entry.timings || [];
|
|
269
|
+
|
|
270
|
+
if (rules.length) {
|
|
271
|
+
for (const rule of rules) {
|
|
272
|
+
const phaseHint = detectPhase(rule) || "open";
|
|
273
|
+
const phaseId = `${groupId}:${phaseHint}`;
|
|
274
|
+
if (!group.phases.has(phaseId)) {
|
|
275
|
+
group.phases.set(phaseId, {
|
|
276
|
+
id: phaseId,
|
|
277
|
+
phase: phaseHint,
|
|
278
|
+
label: phaseHint === "close" ? "Close" : "Open",
|
|
279
|
+
members: [],
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
const timings = parseTransitionDecl(rule);
|
|
283
|
+
const memberTimings = timings.length ? timings : currentTimings;
|
|
284
|
+
group.phases.get(phaseId).members.push({
|
|
285
|
+
id: slug(entry.label || sel),
|
|
286
|
+
label: entry.label || sel,
|
|
287
|
+
selector: sel,
|
|
288
|
+
toState: phaseHint === "close" ? ".is-closing" : ".is-open",
|
|
289
|
+
propertyTimings: memberTimings.map((t) => ({ ...t })),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
const phaseId = `${groupId}:open`;
|
|
294
|
+
if (!group.phases.has(phaseId)) {
|
|
295
|
+
group.phases.set(phaseId, {
|
|
296
|
+
id: phaseId,
|
|
297
|
+
phase: "open",
|
|
298
|
+
label: "Open",
|
|
299
|
+
members: [],
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
group.phases.get(phaseId).members.push({
|
|
303
|
+
id: slug(entry.label || sel),
|
|
304
|
+
label: entry.label || sel,
|
|
305
|
+
selector: sel,
|
|
306
|
+
propertyTimings: currentTimings.map((t) => ({ ...t })),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const groups = [...groupMap.values()].map((g) => ({
|
|
312
|
+
id: g.id,
|
|
313
|
+
label: g.label,
|
|
314
|
+
component: g.component,
|
|
315
|
+
phases: [...g.phases.values()],
|
|
316
|
+
}));
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
groups,
|
|
320
|
+
summary: groups.length ? `Grouped into ${groups.length} component(s).` : "Could not group transitions.",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── apply: edit source ───────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
function findFile(componentHint) {
|
|
327
|
+
if (!componentHint) return null;
|
|
328
|
+
const candidates = [
|
|
329
|
+
join(WORKSPACE, componentHint),
|
|
330
|
+
join(WORKSPACE, "..", componentHint),
|
|
331
|
+
join(process.cwd(), componentHint),
|
|
332
|
+
];
|
|
333
|
+
for (const p of candidates) {
|
|
334
|
+
if (existsSync(p)) return p;
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function replaceTimingInText(text, change) {
|
|
340
|
+
const { property, from, to } = change;
|
|
341
|
+
const prop = property || "all";
|
|
342
|
+
const fromDur = from?.durationMs;
|
|
343
|
+
const toDur = to?.durationMs;
|
|
344
|
+
const toEase = to?.easing;
|
|
345
|
+
|
|
346
|
+
let out = text;
|
|
347
|
+
if (Number.isFinite(fromDur) && Number.isFinite(toDur)) {
|
|
348
|
+
const patterns = [
|
|
349
|
+
new RegExp(`(${prop}\\s+)${fromDur}(ms)`, "g"),
|
|
350
|
+
new RegExp(`(${prop}\\s+)${fromDur / 1000}(s)`, "g"),
|
|
351
|
+
new RegExp(`(transition(?:-duration)?\\s*:\\s*)${fromDur}(ms)`, "g"),
|
|
352
|
+
];
|
|
353
|
+
for (const re of patterns) {
|
|
354
|
+
out = out.replace(re, `$1${toDur}$2`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (toEase && from?.easing && from.easing !== toEase) {
|
|
358
|
+
out = out.replace(new RegExp(escapeRe(from.easing), "g"), toEase);
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function escapeRe(s) {
|
|
364
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function handleApply(request) {
|
|
368
|
+
const file = findFile(request.component);
|
|
369
|
+
if (!file) {
|
|
370
|
+
return {
|
|
371
|
+
applied: false,
|
|
372
|
+
summary: `Could not find source for ${request.component || request.selector}.`,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let text = readFileSync(file, "utf8");
|
|
377
|
+
const changes = request.changes || [];
|
|
378
|
+
let changed = false;
|
|
379
|
+
|
|
380
|
+
for (const c of changes) {
|
|
381
|
+
const next = replaceTimingInText(text, c);
|
|
382
|
+
if (next !== text) {
|
|
383
|
+
text = next;
|
|
384
|
+
changed = true;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (changed) {
|
|
389
|
+
writeFileSync(file, text);
|
|
390
|
+
return {
|
|
391
|
+
applied: true,
|
|
392
|
+
summary: `Updated timings in ${request.label || request.selector}.`,
|
|
393
|
+
files: [`${file}`],
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
applied: false,
|
|
399
|
+
summary: `Found ${file} but could not match declarations to update.`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── dispatch ─────────────────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
async function handleJob(job) {
|
|
406
|
+
const { id, request } = job;
|
|
407
|
+
const kind = request?.kind;
|
|
408
|
+
const label = request?.label || request?.selector || kind || "job";
|
|
409
|
+
log(`▸ job ${id.slice(0, 8)} — ${label} (${kind || request?.refineType || "refine"})`);
|
|
410
|
+
|
|
411
|
+
await post(`/jobs/${id}/status`, { message: "Processing…" });
|
|
412
|
+
|
|
413
|
+
let result;
|
|
414
|
+
if (kind === "scan") {
|
|
415
|
+
result = handleScan(request);
|
|
416
|
+
} else if (kind === "apply") {
|
|
417
|
+
result = handleApply(request);
|
|
418
|
+
} else if (request?.refineType === "replace") {
|
|
419
|
+
result = handleReplaceRefine(request);
|
|
420
|
+
} else {
|
|
421
|
+
result = handleSmallRefine(request);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
await post(`/jobs/${id}/result`, result);
|
|
425
|
+
jobsHandled++;
|
|
426
|
+
log(` ✓ done (${jobsHandled} total)`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function pollOnce() {
|
|
430
|
+
const res = await relayFetch("/jobs/next");
|
|
431
|
+
if (res.status === 204) return false;
|
|
432
|
+
if (!res.ok) throw new Error(`poll HTTP ${res.status}`);
|
|
433
|
+
const job = await res.json();
|
|
434
|
+
// Stop signal from the panel's STOP button (relay POST /poller/stop → the
|
|
435
|
+
// relay answers the next /jobs/next with {stop:true}). Honor it and exit.
|
|
436
|
+
// Without this the {stop:true} body is treated as a malformed job, throws,
|
|
437
|
+
// and the loop re-polls ~2s later — so the relay reports pollerActive again
|
|
438
|
+
// and the panel flips "Live" back on shortly after Stop.
|
|
439
|
+
if (job && job.stop) {
|
|
440
|
+
log(`stop signal received — exiting (jobs=${jobsHandled} errors=${errors})`);
|
|
441
|
+
process.exit(0);
|
|
442
|
+
}
|
|
443
|
+
await handleJob(job);
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function loop() {
|
|
448
|
+
log(`live refine loop → ${RELAY} (workspace: ${WORKSPACE})`);
|
|
449
|
+
// eslint-disable-next-line no-constant-condition
|
|
450
|
+
while (true) {
|
|
451
|
+
try {
|
|
452
|
+
const hadJob = await pollOnce();
|
|
453
|
+
if (!hadJob) await sleep(100);
|
|
454
|
+
} catch (e) {
|
|
455
|
+
errors++;
|
|
456
|
+
log(` ✗ ${e.message} — retry in 2s`);
|
|
457
|
+
await sleep(2000);
|
|
458
|
+
try {
|
|
459
|
+
const h = await relayFetch("/health");
|
|
460
|
+
if (!h.ok) log(" relay health check failed");
|
|
461
|
+
} catch {
|
|
462
|
+
log(" relay unreachable");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
process.on("SIGINT", () => {
|
|
469
|
+
log(`stopped — jobs=${jobsHandled} errors=${errors}`);
|
|
470
|
+
process.exit(0);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
loop();
|
package/server/relay.mjs
CHANGED
|
@@ -91,6 +91,13 @@ const PENDING_TIMEOUT_MS = Number(process.env.REFINE_PENDING_TIMEOUT_MS) || 1200
|
|
|
91
91
|
// to stop (it returns {stop:true} from /jobs/next). 0 disables. Only the chat
|
|
92
92
|
// loop is affected — a wired REFINE_AGENT_CMD never polls /jobs/next.
|
|
93
93
|
const POLLER_IDLE_STOP_MS = Number(process.env.REFINE_POLLER_IDLE_STOP_MS) || 600000;
|
|
94
|
+
// After a Stop (manual or idle), hold the poller "stopped" for this long: every
|
|
95
|
+
// GET /jobs/next keeps getting {stop:true} and /health reports the poller
|
|
96
|
+
// inactive. Without it, a single straggler poll fired right after the stop
|
|
97
|
+
// (an agent mid-backoff, or one that batches a poll before exiting) re-registers
|
|
98
|
+
// as live and the panel flips "Live" back on a few seconds later. A genuine
|
|
99
|
+
// re-run of `/refine live` after the window resumes normally.
|
|
100
|
+
const STOP_GRACE_MS = Number(process.env.REFINE_STOP_GRACE_MS) || 5000;
|
|
94
101
|
|
|
95
102
|
/** @type {Map<string, Job>} */
|
|
96
103
|
const jobs = new Map();
|
|
@@ -99,7 +106,10 @@ const now = () => Date.now();
|
|
|
99
106
|
// When did a `/refine live` agent last poll? Used to know if LLM mode can be
|
|
100
107
|
// served by a live editor agent (vs. needing REFINE_AGENT_CMD).
|
|
101
108
|
let lastPollAt = 0;
|
|
102
|
-
|
|
109
|
+
// While now() < stoppedUntil the relay holds a just-issued Stop (see STOP_GRACE_MS):
|
|
110
|
+
// the poller reports inactive and every /jobs/next answers {stop:true}.
|
|
111
|
+
let stoppedUntil = 0;
|
|
112
|
+
const pollerActive = () => now() >= stoppedUntil && now() - lastPollAt < POLLER_TTL_MS;
|
|
103
113
|
const llmAvailable = () => Boolean(AGENT_CMD) || pollerActive();
|
|
104
114
|
|
|
105
115
|
// Stop signal for the in-chat `/refine live` loop. Set by POST /poller/stop
|
|
@@ -653,6 +663,13 @@ const server = createServer(async (req, res) => {
|
|
|
653
663
|
|
|
654
664
|
// GET /jobs/next — long-poll claimed by a `/refine live` agent (LLM jobs).
|
|
655
665
|
if (method === "GET" && path === "/jobs/next") {
|
|
666
|
+
// Stop-grace window: a Stop was just issued, so keep telling every poller to
|
|
667
|
+
// exit and don't count these polls as live (we deliberately do NOT update
|
|
668
|
+
// lastPollAt here). This stops a straggler poll from reviving the session.
|
|
669
|
+
// A pending job still wins so we never drop real work on the stop edge.
|
|
670
|
+
if (now() < stoppedUntil && !nextPendingLlm()) {
|
|
671
|
+
return send(res, 200, { stop: true });
|
|
672
|
+
}
|
|
656
673
|
lastPollAt = now();
|
|
657
674
|
if (!lastJobAt) lastJobAt = now(); // seed idle window on the loop's first poll
|
|
658
675
|
// Stop signal (manual Stop button or idle auto-stop) → tell the loop to exit.
|
|
@@ -661,15 +678,17 @@ const server = createServer(async (req, res) => {
|
|
|
661
678
|
stopRequested = false;
|
|
662
679
|
lastJobAt = 0;
|
|
663
680
|
lastPollAt = 0; // loop is exiting → report it inactive immediately on /health
|
|
681
|
+
stoppedUntil = now() + STOP_GRACE_MS; // hold the stop so re-polls can't revive it
|
|
664
682
|
return send(res, 200, { stop: true });
|
|
665
683
|
}
|
|
666
684
|
const deadline = now() + LONGPOLL_MS;
|
|
667
685
|
const attempt = () => {
|
|
668
686
|
if (res.writableEnded) return;
|
|
669
|
-
if (stopRequested && !nextPendingLlm()) {
|
|
687
|
+
if ((stopRequested || now() < stoppedUntil) && !nextPendingLlm()) {
|
|
670
688
|
stopRequested = false;
|
|
671
689
|
lastJobAt = 0;
|
|
672
690
|
lastPollAt = 0; // loop is exiting → report it inactive immediately on /health
|
|
691
|
+
stoppedUntil = now() + STOP_GRACE_MS; // hold the stop so re-polls can't revive it
|
|
673
692
|
return send(res, 200, { stop: true });
|
|
674
693
|
}
|
|
675
694
|
const job = nextPendingLlm();
|
|
@@ -688,8 +707,11 @@ const server = createServer(async (req, res) => {
|
|
|
688
707
|
// POST /poller/stop — the panel's "Stop" button. Flags the in-chat loop to
|
|
689
708
|
// exit on its next poll. No-op for a wired REFINE_AGENT_CMD (never polls).
|
|
690
709
|
if (method === "POST" && path === "/poller/stop") {
|
|
710
|
+
const wasActive = now() - lastPollAt < POLLER_TTL_MS;
|
|
691
711
|
stopRequested = true;
|
|
692
|
-
|
|
712
|
+
lastPollAt = 0; // report inactive immediately; a straggler poll won't revive it
|
|
713
|
+
stoppedUntil = now() + STOP_GRACE_MS; // hold the stop through the grace window
|
|
714
|
+
return send(res, 200, { ok: true, stopping: wasActive });
|
|
693
715
|
}
|
|
694
716
|
|
|
695
717
|
const m = path.match(/^\/jobs\/([^/]+)(?:\/(status|result|error))?$/);
|