@codeyam-editor/codeyam-editor 0.1.2 → 0.1.4
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/npm/cloud.js +779 -116
- package/npm/container.js +11 -0
- package/npm/disk-pressure.js +89 -0
- package/npm/editor-dev.js +144 -52
- package/npm/editor.js +47 -17
- package/npm/fleet-branch.js +49 -0
- package/npm/fleet-broker-death.js +44 -0
- package/npm/fleet-broker-debounce.js +63 -0
- package/npm/fleet-broker-status.js +45 -0
- package/npm/fleet-build-data.js +81 -0
- package/npm/fleet-cleanup.js +216 -16
- package/npm/fleet-effect-status.js +179 -0
- package/npm/fleet-grouping.js +56 -0
- package/npm/fleet-job-badge.js +83 -0
- package/npm/fleet-memory.js +35 -5
- package/npm/fleet-plans.js +96 -0
- package/npm/fleet-reconcile.js +58 -1
- package/npm/fleet-reset.js +94 -16
- package/npm/launch-config.js +197 -14
- package/npm/preflight-guidance.js +9 -0
- package/npm/scenario-check.js +106 -1
- package/npm/scenario-handlers.js +16 -0
- package/npm/scenario-interactivity.js +53 -7
- package/npm/scenario-playwright.js +184 -5
- package/npm/utils.js +238 -53
- package/package.json +5 -5
- package/ui/dist/assets/EditorShell-tMMdarZD.js +187 -0
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +5 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-C0YHQB-T.js → ScenarioDataPanelFullPage-5G6KuZgw.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +1 -0
- package/ui/dist/assets/index-BGqairFV.css +1 -0
- package/ui/dist/assets/{index-BUYNFqKK.js → index-BuK0lizt.js} +9 -9
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-DWaeBxgj.js +0 -186
- package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +0 -1
- package/ui/dist/assets/index-DVr5rY2d.css +0 -1
|
@@ -219,6 +219,73 @@ async function waitForAnimationsSettled(
|
|
|
219
219
|
return { settled: false, elapsedMs: Date.now() - started };
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
// Track in-flight network requests on a Playwright page so a capture can wait
|
|
223
|
+
// for client-side data fetches to settle before screenshotting. The
|
|
224
|
+
// resource-timing API (`performance.getEntriesByType("resource")`) only records
|
|
225
|
+
// COMPLETED requests, so it cannot see a fetch that is still in flight — the
|
|
226
|
+
// exact window where a client-fetch page shows a loading skeleton. We count
|
|
227
|
+
// request starts against finishes/failures instead. Returns a live view:
|
|
228
|
+
// `inFlight()` is the current outstanding count and `lastActivityMs()` is the
|
|
229
|
+
// timestamp of the most recent request start OR completion (0 when the page has
|
|
230
|
+
// made no requests since the tracker attached). Attach BEFORE navigation so
|
|
231
|
+
// every request is counted. Stack-agnostic — it observes raw HTTP activity, not
|
|
232
|
+
// any framework's fetch wrapper.
|
|
233
|
+
function createNetworkTracker(page) {
|
|
234
|
+
let inFlight = 0;
|
|
235
|
+
let lastActivityMs = 0;
|
|
236
|
+
const bump = () => {
|
|
237
|
+
lastActivityMs = Date.now();
|
|
238
|
+
};
|
|
239
|
+
if (page && typeof page.on === "function") {
|
|
240
|
+
page.on("request", () => {
|
|
241
|
+
inFlight += 1;
|
|
242
|
+
bump();
|
|
243
|
+
});
|
|
244
|
+
const settle = () => {
|
|
245
|
+
inFlight = Math.max(0, inFlight - 1);
|
|
246
|
+
bump();
|
|
247
|
+
};
|
|
248
|
+
page.on("requestfinished", settle);
|
|
249
|
+
page.on("requestfailed", settle);
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
inFlight: () => inFlight,
|
|
253
|
+
lastActivityMs: () => lastActivityMs,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Bounded network-quiet wait: after the DOM is stable a client-side data fetch
|
|
258
|
+
// can still be in flight — the loading skeleton is gone but the fetched rows
|
|
259
|
+
// haven't replaced it yet, so a screenshot here catches the in-between frame.
|
|
260
|
+
// Wait until no request has been outstanding for `quietWindowMs`, hard-capped at
|
|
261
|
+
// `overallTimeoutMs`. Two properties matter:
|
|
262
|
+
// - A page that made NO requests (lastActivityMs stays 0) is already quiet and
|
|
263
|
+
// returns on the first poll, so server-rendered captures incur no extra wait.
|
|
264
|
+
// - A streaming / long-poll endpoint that never goes idle hits the cap and the
|
|
265
|
+
// caller captures anyway — the wait can never hang the capture.
|
|
266
|
+
async function waitForNetworkQuiet(
|
|
267
|
+
tracker,
|
|
268
|
+
{ quietWindowMs = 500, overallTimeoutMs = 5000, pollIntervalMs = 100 } = {},
|
|
269
|
+
) {
|
|
270
|
+
const started = Date.now();
|
|
271
|
+
while (Date.now() - started < overallTimeoutMs) {
|
|
272
|
+
const idleForMs = Date.now() - tracker.lastActivityMs();
|
|
273
|
+
if (tracker.inFlight() === 0 && idleForMs >= quietWindowMs) {
|
|
274
|
+
const elapsedMs = Date.now() - started;
|
|
275
|
+
logCaptureTiming("network-quiet", { outcome: "quiet", elapsedMs });
|
|
276
|
+
return { quiet: true, elapsedMs };
|
|
277
|
+
}
|
|
278
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
279
|
+
}
|
|
280
|
+
const elapsedMs = Date.now() - started;
|
|
281
|
+
logCaptureTiming("network-quiet", {
|
|
282
|
+
outcome: "capped",
|
|
283
|
+
elapsedMs,
|
|
284
|
+
inFlight: tracker.inFlight(),
|
|
285
|
+
});
|
|
286
|
+
return { quiet: false, elapsedMs };
|
|
287
|
+
}
|
|
288
|
+
|
|
222
289
|
// `loadingMarkers` are the project's app-specific loading strings (from
|
|
223
290
|
// stack.json `capture.loadingMarkers`); they extend the codeyam-harness
|
|
224
291
|
// defaults so a stable-but-still-loading app screen counts as "not ready"
|
|
@@ -233,15 +300,41 @@ async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers
|
|
|
233
300
|
while (Date.now() - started < timeoutMs) {
|
|
234
301
|
await page.waitForTimeout(500);
|
|
235
302
|
|
|
236
|
-
const pageState = await target.evaluate(() =>
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
303
|
+
const pageState = await target.evaluate(() => {
|
|
304
|
+
const getById = document.getElementById;
|
|
305
|
+
const root =
|
|
306
|
+
typeof getById === "function" ? document.getElementById("root") : null;
|
|
307
|
+
return {
|
|
308
|
+
bodyText: document.body?.innerText ?? "",
|
|
309
|
+
html: document.body?.innerHTML ?? "",
|
|
310
|
+
// Whether the SPA mount point exists AND has painted anything. An
|
|
311
|
+
// existing-but-empty `<div id="root">` is the pre-paint window of a
|
|
312
|
+
// slow-first-paint scenario (e.g. a live-session app that spends a few
|
|
313
|
+
// seconds connecting before the gate UI mounts). `rootExists` lets us
|
|
314
|
+
// distinguish that from a mid-redirect/teardown `null` body, where
|
|
315
|
+
// there is no root to wait on and a stable-empty page is legitimately
|
|
316
|
+
// settled.
|
|
317
|
+
rootExists: !!root,
|
|
318
|
+
rootChildCount: root ? root.childElementCount : 0,
|
|
319
|
+
};
|
|
320
|
+
});
|
|
240
321
|
|
|
241
322
|
lastHadLoadingMarkers = hasLoadingMarkers(pageState.bodyText, loadingMarkers);
|
|
242
323
|
lastHtmlChanged = pageState.html !== lastHtml;
|
|
243
324
|
|
|
244
|
-
|
|
325
|
+
// A mounted-but-unpainted root is "still loading", not "settled": the HTML
|
|
326
|
+
// can sit byte-stable for a second or two while the SPA boots, which would
|
|
327
|
+
// otherwise satisfy the stability check and capture a blank frame before
|
|
328
|
+
// first paint. Treat an existing root with zero children AND no body text
|
|
329
|
+
// as not-ready so the loop keeps polling until the app actually paints (or
|
|
330
|
+
// the overall timeout fires, by which point real content is present). A
|
|
331
|
+
// `null` body (rootExists=false) is unaffected — it stays trivially stable.
|
|
332
|
+
const rootUnpainted =
|
|
333
|
+
pageState.rootExists &&
|
|
334
|
+
pageState.rootChildCount === 0 &&
|
|
335
|
+
(pageState.bodyText ?? "").trim().length === 0;
|
|
336
|
+
|
|
337
|
+
if (!lastHadLoadingMarkers && !lastHtmlChanged && !rootUnpainted) {
|
|
245
338
|
stableCount += 1;
|
|
246
339
|
if (stableCount >= 2) {
|
|
247
340
|
const remaining = () => Math.max(0, timeoutMs - (Date.now() - started));
|
|
@@ -339,6 +432,88 @@ async function loadScenarioTopLevel(
|
|
|
339
432
|
return { frame: page.mainFrame(), response };
|
|
340
433
|
}
|
|
341
434
|
|
|
435
|
+
// Collect up to 20 distinct visible labels of interactive elements on the
|
|
436
|
+
// page — buttons, links, role=button, form controls, <summary>, and anything
|
|
437
|
+
// with an onclick. Used to build an ACTIONABLE error when an interaction's
|
|
438
|
+
// target matches nothing: the agent reliably knows a label it rendered, so
|
|
439
|
+
// listing the real candidates turns a silent blank capture into a "did you
|
|
440
|
+
// mean one of these?" hint. Pure read (no clicks); falls back to value /
|
|
441
|
+
// aria-label / placeholder when an element has no text.
|
|
442
|
+
async function collectInteractiveLabels(frame) {
|
|
443
|
+
return frame.evaluate(() => {
|
|
444
|
+
const selector =
|
|
445
|
+
"button, a[href], [role=button], input, select, textarea, summary, [onclick]";
|
|
446
|
+
const nodes = Array.from(document.querySelectorAll(selector));
|
|
447
|
+
const labels = nodes
|
|
448
|
+
.map((node) => {
|
|
449
|
+
const text =
|
|
450
|
+
(node.innerText || node.textContent || "").trim() ||
|
|
451
|
+
(typeof node.value === "string" ? node.value.trim() : "") ||
|
|
452
|
+
(node.getAttribute && node.getAttribute("aria-label")) ||
|
|
453
|
+
(node.getAttribute && node.getAttribute("placeholder")) ||
|
|
454
|
+
"";
|
|
455
|
+
return String(text).trim();
|
|
456
|
+
})
|
|
457
|
+
.filter((label) => label.length > 0);
|
|
458
|
+
return Array.from(new Set(labels)).slice(0, 20);
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Drive a single user-style interaction against the settled frame before the
|
|
463
|
+
// screenshot, so an interactive state (expanded accordion, open modal, filled
|
|
464
|
+
// field) can be captured without editing app source.
|
|
465
|
+
//
|
|
466
|
+
// The target is matched by visible `text` (preferred — the agent reliably
|
|
467
|
+
// knows the label it rendered) or a CSS `selector`. `action` is click / fill /
|
|
468
|
+
// press; `value` carries the text for `fill` or the key for `press` (e.g.
|
|
469
|
+
// `Enter`). On a no-match target this THROWS with the list of candidate
|
|
470
|
+
// interactive labels — the capture script's outer catch turns that into a
|
|
471
|
+
// failed capture with an actionable message, never a silent blank screenshot.
|
|
472
|
+
async function performInteraction(frame, interaction, { timeoutMs = 5000 } = {}) {
|
|
473
|
+
const { action, selector, text, value } = interaction || {};
|
|
474
|
+
|
|
475
|
+
let locator;
|
|
476
|
+
let targetDesc;
|
|
477
|
+
if (typeof text === "string" && text.length > 0) {
|
|
478
|
+
locator = frame.getByText(text, { exact: false }).first();
|
|
479
|
+
targetDesc = `text "${text}"`;
|
|
480
|
+
} else if (typeof selector === "string" && selector.length > 0) {
|
|
481
|
+
locator = frame.locator(selector).first();
|
|
482
|
+
targetDesc = `selector "${selector}"`;
|
|
483
|
+
} else {
|
|
484
|
+
throw new Error(
|
|
485
|
+
"preview-interact: interaction requires a `text` or `selector` target",
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const matchCount = await locator.count();
|
|
490
|
+
if (matchCount === 0) {
|
|
491
|
+
const candidates = await collectInteractiveLabels(frame);
|
|
492
|
+
const candidateList =
|
|
493
|
+
candidates.length > 0 ? candidates.join(", ") : "(none found on page)";
|
|
494
|
+
throw new Error(
|
|
495
|
+
`preview-interact: no element matched ${targetDesc}. ` +
|
|
496
|
+
`Candidate interactive labels: ${candidateList}`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
switch (action) {
|
|
501
|
+
case "click":
|
|
502
|
+
await locator.click({ timeout: timeoutMs });
|
|
503
|
+
break;
|
|
504
|
+
case "fill":
|
|
505
|
+
await locator.fill(value ?? "", { timeout: timeoutMs });
|
|
506
|
+
break;
|
|
507
|
+
case "press":
|
|
508
|
+
await locator.press(value || "Enter", { timeout: timeoutMs });
|
|
509
|
+
break;
|
|
510
|
+
default:
|
|
511
|
+
throw new Error(
|
|
512
|
+
`preview-interact: unknown action "${action}" (expected click | fill | press)`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
342
517
|
module.exports = {
|
|
343
518
|
logCaptureTiming,
|
|
344
519
|
resolveTcpTarget,
|
|
@@ -349,7 +524,11 @@ module.exports = {
|
|
|
349
524
|
collectImageStates,
|
|
350
525
|
waitForImagesSettled,
|
|
351
526
|
waitForAnimationsSettled,
|
|
527
|
+
createNetworkTracker,
|
|
528
|
+
waitForNetworkQuiet,
|
|
352
529
|
waitForStablePage,
|
|
353
530
|
loadScenarioInIframe,
|
|
354
531
|
loadScenarioTopLevel,
|
|
532
|
+
collectInteractiveLabels,
|
|
533
|
+
performInteraction,
|
|
355
534
|
};
|
package/npm/utils.js
CHANGED
|
@@ -8,14 +8,20 @@ const serverStatePath = path.join(rootDir, ".codeyam", "server-state.json");
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Map (process.platform, process.arch) → the platform sub-package that
|
|
11
|
-
* carries the matching native
|
|
12
|
-
*
|
|
11
|
+
* carries the matching native binaries. Each sub-package ships TWO
|
|
12
|
+
* binaries side-by-side in its `bin/` dir: the `codeyam-editor` monolith
|
|
13
|
+
* and the dedicated `codeyam-editor-pty-broker` daemon (its own
|
|
14
|
+
* executable file / inode, for a stable honest process name). They live
|
|
15
|
+
* in the same dir so the broker auto-spawn's sibling-of-current-exe
|
|
16
|
+
* resolution finds the broker next to the monolith with no copying.
|
|
17
|
+
* Kept as a small data table so adding a new platform (e.g. linux/arm64)
|
|
18
|
+
* is one entry, not a new branch.
|
|
13
19
|
*/
|
|
14
20
|
const PLATFORM_PACKAGES = {
|
|
15
|
-
"darwin-arm64": { pkg: "@codeyam-editor/codeyam-editor-darwin-arm64", binary: "codeyam-editor" },
|
|
16
|
-
"darwin-x64": { pkg: "@codeyam-editor/codeyam-editor-darwin-x64", binary: "codeyam-editor" },
|
|
17
|
-
"linux-x64": { pkg: "@codeyam-editor/codeyam-editor-linux-x64", binary: "codeyam-editor" },
|
|
18
|
-
"win32-x64": { pkg: "@codeyam-editor/codeyam-editor-win32-x64", binary: "codeyam-editor.exe" },
|
|
21
|
+
"darwin-arm64": { pkg: "@codeyam-editor/codeyam-editor-darwin-arm64", binary: "codeyam-editor", broker: "codeyam-editor-pty-broker" },
|
|
22
|
+
"darwin-x64": { pkg: "@codeyam-editor/codeyam-editor-darwin-x64", binary: "codeyam-editor", broker: "codeyam-editor-pty-broker" },
|
|
23
|
+
"linux-x64": { pkg: "@codeyam-editor/codeyam-editor-linux-x64", binary: "codeyam-editor", broker: "codeyam-editor-pty-broker" },
|
|
24
|
+
"win32-x64": { pkg: "@codeyam-editor/codeyam-editor-win32-x64", binary: "codeyam-editor.exe", broker: "codeyam-editor-pty-broker.exe" },
|
|
19
25
|
};
|
|
20
26
|
|
|
21
27
|
/**
|
|
@@ -27,6 +33,14 @@ function binaryName(platform = process.platform) {
|
|
|
27
33
|
return platform === "win32" ? "codeyam-editor.exe" : "codeyam-editor";
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Platform-correct executable filename for the dedicated broker binary.
|
|
38
|
+
* Mirrors {@link binaryName} for the second shipped binary.
|
|
39
|
+
*/
|
|
40
|
+
function brokerBinaryName(platform = process.platform) {
|
|
41
|
+
return platform === "win32" ? "codeyam-editor-pty-broker.exe" : "codeyam-editor-pty-broker";
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
/** Look up the platform sub-package metadata for a given platform/arch pair. */
|
|
31
45
|
function platformPackageInfo(platform = process.platform, arch = process.arch) {
|
|
32
46
|
return PLATFORM_PACKAGES[`${platform}-${arch}`] || null;
|
|
@@ -62,10 +76,25 @@ function findBinary() {
|
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
const name = binaryName();
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
// Target directories to search, in precedence order. A custom
|
|
80
|
+
// CARGO_TARGET_DIR (cloud VMs set CARGO_TARGET_DIR=/codeyam-build-target)
|
|
81
|
+
// wins over the default ./target — cargo builds ONLY into the configured
|
|
82
|
+
// dir, so ./target may be stale or absent. Honoring it here keeps
|
|
83
|
+
// `ensureBinary` from building successfully and then failing to locate the
|
|
84
|
+
// result. CARGO_TARGET_DIR may be absolute or relative to the workspace
|
|
85
|
+
// root; path.resolve handles both (an absolute value ignores rootDir).
|
|
86
|
+
const targetDirs = [];
|
|
87
|
+
const customTarget = process.env.CARGO_TARGET_DIR;
|
|
88
|
+
if (customTarget) {
|
|
89
|
+
targetDirs.push(path.resolve(rootDir, customTarget));
|
|
90
|
+
}
|
|
91
|
+
targetDirs.push(path.join(rootDir, "target"));
|
|
92
|
+
|
|
93
|
+
const devCandidates = [];
|
|
94
|
+
for (const dir of targetDirs) {
|
|
95
|
+
devCandidates.push(path.join(dir, "debug", name));
|
|
96
|
+
devCandidates.push(path.join(dir, "release", name));
|
|
97
|
+
}
|
|
69
98
|
for (const candidate of devCandidates) {
|
|
70
99
|
if (fs.existsSync(candidate)) return candidate;
|
|
71
100
|
}
|
|
@@ -133,48 +162,71 @@ function ensureBinary() {
|
|
|
133
162
|
console.error("Binary not found after build.");
|
|
134
163
|
process.exit(1);
|
|
135
164
|
}
|
|
165
|
+
verifyBrokerBinaryPresent(binary);
|
|
136
166
|
return binary;
|
|
137
167
|
}
|
|
138
168
|
|
|
139
|
-
/** Resolve the UI dist directory for the binary to serve. */
|
|
140
|
-
function uiDistDir() {
|
|
141
|
-
return path.join(rootDir, "ui", "dist");
|
|
142
|
-
}
|
|
143
|
-
|
|
144
169
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* Pure and synchronous (uses `fs.realpathSync`). On a realpath failure (an
|
|
153
|
-
* unreadable path, race with a moved file) the un-resolved value is used as
|
|
154
|
-
* a fallback — the same defensive shape `canonicalisePath` uses, so the
|
|
155
|
-
* wrapper never bails on a transient fs hiccup.
|
|
156
|
-
*
|
|
157
|
-
* Gate for the dev wrapper's passthrough `cargo build`: when the target
|
|
158
|
-
* project is the editor itself, rebuilding makes sense (the developer is
|
|
159
|
-
* iterating on editor source). When it is any other project, the rebuild
|
|
160
|
-
* does nothing of value and ripples into the stale-binary watcher's
|
|
161
|
-
* SIGUSR1 re-exec, resetting the user's session for no reason. The
|
|
162
|
-
* `CODEYAM_DEV_REBUILD=1` env preserves the one legitimate "editing the
|
|
163
|
-
* editor against a client project" workflow.
|
|
170
|
+
* Resolve the dedicated broker binary path. Mirrors {@link findBinary}:
|
|
171
|
+
* the published platform sub-package ships it next to the monolith, and
|
|
172
|
+
* a dev `cargo build` produces it under target/debug|release. Returns
|
|
173
|
+
* null if it can't be located — the Rust broker auto-spawn then falls
|
|
174
|
+
* back to the monolith `editor pty-broker daemon` shim, so a missing
|
|
175
|
+
* broker binary degrades gracefully rather than breaking the editor.
|
|
164
176
|
*/
|
|
165
|
-
function
|
|
166
|
-
const
|
|
177
|
+
function findBrokerBinary() {
|
|
178
|
+
const info = platformPackageInfo();
|
|
179
|
+
if (info && info.broker) {
|
|
167
180
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
181
|
+
const resolved = require.resolve(`${info.pkg}/bin/${info.broker}`, {
|
|
182
|
+
paths: [rootDir],
|
|
183
|
+
});
|
|
184
|
+
if (fs.existsSync(resolved)) return resolved;
|
|
185
|
+
} catch (e) {
|
|
186
|
+
if (e.code !== "MODULE_NOT_FOUND") throw e;
|
|
171
187
|
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const name = brokerBinaryName();
|
|
191
|
+
const targetDirs = [];
|
|
192
|
+
const customTarget = process.env.CARGO_TARGET_DIR;
|
|
193
|
+
if (customTarget) {
|
|
194
|
+
targetDirs.push(path.resolve(rootDir, customTarget));
|
|
195
|
+
}
|
|
196
|
+
targetDirs.push(path.join(rootDir, "target"));
|
|
197
|
+
|
|
198
|
+
for (const dir of targetDirs) {
|
|
199
|
+
for (const profile of ["debug", "release"]) {
|
|
200
|
+
const candidate = path.join(dir, profile, name);
|
|
201
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Best-effort check that the dedicated broker binary sits next to the
|
|
209
|
+
* monolith (the layout the broker auto-spawn's sibling resolution
|
|
210
|
+
* expects). Logs a one-line note when it's missing — never throws or
|
|
211
|
+
* exits, because the Rust side degrades to the monolith shim. `monolith`
|
|
212
|
+
* is the resolved monolith binary path; the broker is expected as its
|
|
213
|
+
* sibling.
|
|
214
|
+
*/
|
|
215
|
+
function verifyBrokerBinaryPresent(monolith) {
|
|
216
|
+
const sibling = path.join(path.dirname(monolith), brokerBinaryName());
|
|
217
|
+
if (fs.existsSync(sibling)) return;
|
|
218
|
+
const located = findBrokerBinary();
|
|
219
|
+
if (located) return;
|
|
220
|
+
console.error(
|
|
221
|
+
"Note: dedicated broker binary (codeyam-editor-pty-broker) not found next to " +
|
|
222
|
+
`${monolith}. The PTY broker will fall back to the monolith \`editor pty-broker daemon\` ` +
|
|
223
|
+
"shim. Reinstall the platform sub-package, or run `cargo build -p codeyam-pty-broker`."
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Resolve the UI dist directory for the binary to serve. */
|
|
228
|
+
function uiDistDir() {
|
|
229
|
+
return path.join(rootDir, "ui", "dist");
|
|
178
230
|
}
|
|
179
231
|
|
|
180
232
|
/**
|
|
@@ -568,6 +620,114 @@ function tryKillPid(pid) {
|
|
|
568
620
|
}
|
|
569
621
|
}
|
|
570
622
|
|
|
623
|
+
/**
|
|
624
|
+
* True when the launcher is attached to an interactive terminal — i.e. a human
|
|
625
|
+
* could have typed Ctrl+C. Headless / agent-driven launchers (cloud containers,
|
|
626
|
+
* `npm run editor` under a supervisor) have no tty here. Used to decide whether a
|
|
627
|
+
* received SIGINT is a deliberate "stop the editor" (forward it) or a stray
|
|
628
|
+
* group / spurious signal that must NOT reap the now-detached server.
|
|
629
|
+
*/
|
|
630
|
+
function isInteractiveLauncher(stream = process.stdout) {
|
|
631
|
+
return Boolean(stream && stream.isTTY);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Spawn options for the editor server child. The server is spawned `detached:
|
|
636
|
+
* true` so it leads its OWN session + process group (setsid), decoupling its
|
|
637
|
+
* lifetime from the launcher's controlling terminal and process group — a stray
|
|
638
|
+
* tty Ctrl+C or a group-wide `kill(-pgid, SIGINT)` can no longer reach it. stdio
|
|
639
|
+
* is detached from the terminal for the same reason (an inherited tty can still
|
|
640
|
+
* deliver tty signals to a detached child): stdout/stderr go to `logFd` when one
|
|
641
|
+
* is supplied, else are ignored — the Rust server writes its own
|
|
642
|
+
* `.codeyam/logs/editor-server.log` regardless.
|
|
643
|
+
*/
|
|
644
|
+
function buildServerSpawnOptions({ cwd, env, logFd = null }) {
|
|
645
|
+
return {
|
|
646
|
+
stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
|
|
647
|
+
cwd,
|
|
648
|
+
detached: true,
|
|
649
|
+
env,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Open (creating dirs as needed) the launcher-managed server stdout/stderr log
|
|
655
|
+
* in append mode and return its fd, or null if it can't be opened. Kept separate
|
|
656
|
+
* from the Rust server's own structured `editor-server.log` so the two streams
|
|
657
|
+
* never interleave. Best-effort: a failure here just means the detached server's
|
|
658
|
+
* stdio is ignored, never that the launch itself fails.
|
|
659
|
+
*/
|
|
660
|
+
function openServerOutputLog(cwd) {
|
|
661
|
+
try {
|
|
662
|
+
const dir = path.join(cwd, ".codeyam", "logs");
|
|
663
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
664
|
+
return fs.openSync(path.join(dir, "editor-server.out.log"), "a");
|
|
665
|
+
} catch {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Format one launcher-teardown-signal record for
|
|
672
|
+
* `.codeyam/logs/launcher-signals.log`. Pure: every input is passed in, so the
|
|
673
|
+
* formatting is unit-testable without a real signal or `ps`. Captures the
|
|
674
|
+
* launcher's own pid/ppid/pgid, whether it was interactive (a tty Ctrl+C) vs
|
|
675
|
+
* headless (a stray/spurious signal), the decision taken (forwarded teardown vs
|
|
676
|
+
* kept the server alive), and a best-effort `ps` group snapshot so the SENDER of
|
|
677
|
+
* a spurious `kill()` can be identified on the next repro.
|
|
678
|
+
*/
|
|
679
|
+
function formatLauncherSignalRecord({ signal, pid, ppid, pgid, interactive, forwarded, ps, timestamp }) {
|
|
680
|
+
const lines = [
|
|
681
|
+
`[${timestamp}] launcher-signal signal=${signal} pid=${pid} ppid=${ppid} pgid=${pgid} ` +
|
|
682
|
+
`interactive=${interactive} action=${forwarded ? "forwarded-teardown" : "ignored-kept-server-alive"}`,
|
|
683
|
+
];
|
|
684
|
+
if (ps && ps.trim() !== "") {
|
|
685
|
+
lines.push(ps.trimEnd());
|
|
686
|
+
}
|
|
687
|
+
return lines.join("\n") + "\n";
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Append a launcher-teardown-signal forensic record. Called from each launcher's
|
|
692
|
+
* SIGINT handler BEFORE it decides whether to forward, so a server reaped by a
|
|
693
|
+
* stray group SIGINT (or a spurious `kill()` to a headless launcher) always
|
|
694
|
+
* leaves a trail naming the launcher's process group. Best-effort: never throws,
|
|
695
|
+
* so a logging failure can never block the teardown decision.
|
|
696
|
+
*/
|
|
697
|
+
function logLauncherTeardownSignal(signal, projectDir, { interactive, forwarded }) {
|
|
698
|
+
try {
|
|
699
|
+
const pid = process.pid;
|
|
700
|
+
const ppid = typeof process.ppid === "number" ? process.ppid : -1;
|
|
701
|
+
let pgid = -1;
|
|
702
|
+
let ps = "";
|
|
703
|
+
if (process.platform !== "win32") {
|
|
704
|
+
const pgidOut = spawnSync("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8" });
|
|
705
|
+
if (!pgidOut.error) {
|
|
706
|
+
pgid = parseInt(pgidOut.stdout.trim(), 10);
|
|
707
|
+
}
|
|
708
|
+
if (Number.isInteger(pgid) && pgid > 0) {
|
|
709
|
+
const grp = spawnSync("ps", ["-o", "pid,ppid,pgid,tpgid,comm", "-g", String(pgid)], { encoding: "utf8" });
|
|
710
|
+
if (!grp.error) ps = grp.stdout;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const dir = path.join(projectDir, ".codeyam", "logs");
|
|
714
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
715
|
+
const record = formatLauncherSignalRecord({
|
|
716
|
+
signal,
|
|
717
|
+
pid,
|
|
718
|
+
ppid,
|
|
719
|
+
pgid,
|
|
720
|
+
interactive: Boolean(interactive),
|
|
721
|
+
forwarded: Boolean(forwarded),
|
|
722
|
+
ps,
|
|
723
|
+
timestamp: new Date().toISOString(),
|
|
724
|
+
});
|
|
725
|
+
fs.appendFileSync(path.join(dir, "launcher-signals.log"), record);
|
|
726
|
+
} catch {
|
|
727
|
+
/* best-effort forensics — never block teardown on a logging failure */
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
571
731
|
/**
|
|
572
732
|
* Send the platform-correct termination signal to a spawned child process.
|
|
573
733
|
* Used by editor.js / editor-dev.js / container.js when the user hits Ctrl+C —
|
|
@@ -674,12 +834,29 @@ async function ensureServer(port = 14199) {
|
|
|
674
834
|
const binary = ensureBinary();
|
|
675
835
|
// UI bundle is embedded in the Rust binary; CODEYAM_EDITOR_UI_DIR is
|
|
676
836
|
// now an explicit dev-override only.
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
837
|
+
//
|
|
838
|
+
// The server is spawned into its OWN session/process group (detached: true)
|
|
839
|
+
// with stdio detached from the launcher's controlling terminal, so a stray
|
|
840
|
+
// tty Ctrl+C or a group-wide SIGINT can no longer reap it — its lifetime is
|
|
841
|
+
// governed only by the launcher's deliberate teardown forward and the
|
|
842
|
+
// pidfile / API-shutdown paths. stdout/stderr are appended to
|
|
843
|
+
// `.codeyam/logs/editor-server.out.log` (the Rust server also writes its own
|
|
844
|
+
// editor-server.log). `unref()` keeps the detached child from pinning the
|
|
845
|
+
// launcher's event loop alive on its own.
|
|
846
|
+
const logFd = openServerOutputLog(process.cwd());
|
|
847
|
+
const child = spawn(
|
|
848
|
+
binary,
|
|
849
|
+
["start", "--no-open", "--port", String(port)],
|
|
850
|
+
buildServerSpawnOptions({ cwd: process.cwd(), env: process.env, logFd }),
|
|
851
|
+
);
|
|
852
|
+
if (logFd !== null) {
|
|
853
|
+
try {
|
|
854
|
+
fs.closeSync(logFd);
|
|
855
|
+
} catch {
|
|
856
|
+
/* child holds its own dup of the fd; the launcher's copy is no longer needed */
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
child.unref();
|
|
683
860
|
|
|
684
861
|
const { outcome, exitedBeforeBind } = await awaitServerReadyOrChildExit(child, port);
|
|
685
862
|
|
|
@@ -688,7 +865,8 @@ async function ensureServer(port = 14199) {
|
|
|
688
865
|
const detail = signal != null ? `signal ${signal}` : `code ${code}`;
|
|
689
866
|
console.error(
|
|
690
867
|
`codeyam-editor server exited (${detail}) before binding port ${port}. ` +
|
|
691
|
-
"Likely a preflight bail; see
|
|
868
|
+
"Likely a preflight bail; see .codeyam/logs/editor-server.out.log " +
|
|
869
|
+
"and .codeyam/logs/editor-server.log.",
|
|
692
870
|
);
|
|
693
871
|
// Always propagate as a real failure — even on code 0 the caller
|
|
694
872
|
// needs to know the server isn't running.
|
|
@@ -734,11 +912,13 @@ module.exports = {
|
|
|
734
912
|
rootDir,
|
|
735
913
|
serverStatePath,
|
|
736
914
|
binaryName,
|
|
915
|
+
brokerBinaryName,
|
|
737
916
|
platformPackageInfo,
|
|
738
917
|
PLATFORM_PACKAGES,
|
|
739
918
|
findBinary,
|
|
919
|
+
findBrokerBinary,
|
|
920
|
+
verifyBrokerBinaryPresent,
|
|
740
921
|
ensureBinary,
|
|
741
|
-
isSelfHostTarget,
|
|
742
922
|
staleBinaryBanner,
|
|
743
923
|
enforceFreshBinary,
|
|
744
924
|
uiDistDir,
|
|
@@ -760,6 +940,11 @@ module.exports = {
|
|
|
760
940
|
restartServer,
|
|
761
941
|
ensureServer,
|
|
762
942
|
awaitServerReadyOrChildExit,
|
|
943
|
+
isInteractiveLauncher,
|
|
944
|
+
buildServerSpawnOptions,
|
|
945
|
+
openServerOutputLog,
|
|
946
|
+
formatLauncherSignalRecord,
|
|
947
|
+
logLauncherTeardownSignal,
|
|
763
948
|
killChildProcess,
|
|
764
949
|
deepMergeJson,
|
|
765
950
|
stripUnderscoreTopLevelKeys,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codeyam-editor/codeyam-editor",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Language-agnostic managed execution sandbox for scenario-driven development",
|
|
5
5
|
"bin": {
|
|
6
6
|
"codeyam-editor": "./npm/cli.js"
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"playwright": "^1.58.2"
|
|
10
10
|
},
|
|
11
11
|
"optionalDependencies": {
|
|
12
|
-
"@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.
|
|
13
|
-
"@codeyam-editor/codeyam-editor-darwin-x64": "0.1.
|
|
14
|
-
"@codeyam-editor/codeyam-editor-linux-x64": "0.1.
|
|
15
|
-
"@codeyam-editor/codeyam-editor-win32-x64": "0.1.
|
|
12
|
+
"@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.4",
|
|
13
|
+
"@codeyam-editor/codeyam-editor-darwin-x64": "0.1.4",
|
|
14
|
+
"@codeyam-editor/codeyam-editor-linux-x64": "0.1.4",
|
|
15
|
+
"@codeyam-editor/codeyam-editor-win32-x64": "0.1.4"
|
|
16
16
|
},
|
|
17
17
|
"keywords": [
|
|
18
18
|
"codeyam",
|