@codeyam-editor/codeyam-editor 0.1.4 → 0.1.5
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-branch-switch.js +1 -1
- package/npm/cloud.js +1415 -73
- package/npm/container.js +3 -0
- package/npm/disk-pressure.js +78 -0
- package/npm/editor-dev.js +96 -15
- package/npm/editor.js +85 -37
- package/npm/fleet-auth-health.js +75 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +51 -2
- package/npm/fleet-cleanup.js +118 -7
- package/npm/fleet-destroy-safety.js +55 -0
- package/npm/fleet-effect-status.js +33 -32
- package/npm/fleet-host.js +74 -0
- package/npm/fleet-job-badge.js +1 -1
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +123 -0
- package/npm/fleet-plans.js +135 -0
- package/npm/fleet-prewarm.js +165 -0
- package/npm/fleet-public-url.js +160 -0
- package/npm/fleet-queue.js +38 -1
- package/npm/fleet-reconcile.js +43 -1
- package/npm/fleet-reset.js +298 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-setup-steps.js +105 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-strand.js +150 -0
- package/npm/fleet-stuck-status.js +111 -0
- package/npm/fleet-vm-access.js +234 -0
- package/npm/fleet-vm-readiness.js +104 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +211 -0
- package/npm/ingress-invoke.js +292 -0
- package/npm/launch-config.js +204 -3
- package/npm/scenario-check.js +603 -32
- package/npm/scenario-handlers.js +78 -12
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +20 -0
- package/npm/scenario-playwright.js +366 -29
- package/npm/script-drift.js +81 -0
- package/npm/utils.js +329 -50
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
- package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
- package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
- package/ui/dist/assets/index-CIXZkwfB.css +1 -0
- package/ui/dist/assets/{index-BuK0lizt.js → index-QsOiOG9x.js} +9 -9
- package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-BGqairFV.css +0 -1
- package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
package/npm/utils.js
CHANGED
|
@@ -2,6 +2,7 @@ const { execSync, spawn, spawnSync } = require("child_process");
|
|
|
2
2
|
const net = require("net");
|
|
3
3
|
const path = require("path");
|
|
4
4
|
const fs = require("fs");
|
|
5
|
+
const readline = require("readline");
|
|
5
6
|
|
|
6
7
|
const rootDir = path.resolve(__dirname, "..");
|
|
7
8
|
const serverStatePath = path.join(rootDir, ".codeyam", "server-state.json");
|
|
@@ -276,6 +277,25 @@ function enforceFreshBinary(buildOk, { action, continueVerb = "continuing with"
|
|
|
276
277
|
);
|
|
277
278
|
}
|
|
278
279
|
|
|
280
|
+
// Default boot budget for the editor server. A real cold boot legitimately
|
|
281
|
+
// spends ~7–10s on plan-claims git migration + reap, a PTY-broker probe/respawn
|
|
282
|
+
// (worse when a stale orphan broker's socket is dead), and a branch-tip fetch.
|
|
283
|
+
// 30s is ~3× that — comfortable headroom without letting a genuinely hung
|
|
284
|
+
// server hang indefinitely.
|
|
285
|
+
const DEFAULT_START_TIMEOUT_MS = 30000;
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Resolve the boot budget (ms) for `ensureServer`, honoring the
|
|
289
|
+
* `CODEYAM_EDITOR_START_TIMEOUT_MS` override. Mirrors the existing env-read
|
|
290
|
+
* style (`CODEYAM_ALLOW_STALE`): parse the raw value, accept only a positive
|
|
291
|
+
* integer, and fall back to the 30s default on missing/non-numeric/≤0 input.
|
|
292
|
+
* Exported so it's unit-testable without spawning the real binary.
|
|
293
|
+
*/
|
|
294
|
+
function resolveStartTimeoutMs(env = process.env) {
|
|
295
|
+
const parsed = Number.parseInt(env.CODEYAM_EDITOR_START_TIMEOUT_MS, 10);
|
|
296
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_START_TIMEOUT_MS;
|
|
297
|
+
}
|
|
298
|
+
|
|
279
299
|
function isPlainJsonObject(value) {
|
|
280
300
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
281
301
|
}
|
|
@@ -491,9 +511,14 @@ async function requestServerShutdown(port = 14199, timeoutMs = 1500) {
|
|
|
491
511
|
|
|
492
512
|
/**
|
|
493
513
|
* GET /api/codeyam-server-identity from the listener on `port` with a
|
|
494
|
-
* sub-second timeout. Returns the parsed
|
|
495
|
-
*
|
|
496
|
-
*
|
|
514
|
+
* sub-second timeout. Returns the parsed identity body on a 2xx response,
|
|
515
|
+
* or `null` on any failure (timeout, non-200, parse failure, connection
|
|
516
|
+
* refused). Two identity shapes are recognized:
|
|
517
|
+
* - a per-project editor: `{projectDir, pid, version, startedAt}`
|
|
518
|
+
* - the Project Launcher (selector): `{role: "launcher", pid, version}`
|
|
519
|
+
* — deliberately NO `projectDir`, since the selector isn't bound to one
|
|
520
|
+
* project. We must NOT reject it for the missing `projectDir`, or the
|
|
521
|
+
* wrapper would misclassify the live selector as a foreign squatter.
|
|
497
522
|
*
|
|
498
523
|
* `null` carries two distinct meanings that the caller must
|
|
499
524
|
* disambiguate by also checking `isPortInUse`:
|
|
@@ -513,9 +538,10 @@ async function fetchServerIdentity(port, timeoutMs = 500) {
|
|
|
513
538
|
});
|
|
514
539
|
if (!resp.ok) return null;
|
|
515
540
|
const body = await resp.json();
|
|
516
|
-
if (!body || typeof body !== "object"
|
|
517
|
-
|
|
518
|
-
|
|
541
|
+
if (!body || typeof body !== "object") return null;
|
|
542
|
+
// The Project Launcher advertises a `launcher` role and no projectDir.
|
|
543
|
+
if (body.role === "launcher") return body;
|
|
544
|
+
if (typeof body.projectDir !== "string") return null;
|
|
519
545
|
return body;
|
|
520
546
|
} catch {
|
|
521
547
|
return null;
|
|
@@ -572,11 +598,47 @@ function formatCrossProjectError(port, holder, verb) {
|
|
|
572
598
|
);
|
|
573
599
|
}
|
|
574
600
|
|
|
601
|
+
/**
|
|
602
|
+
* Build the "server never bound the port" failure message, shown only after
|
|
603
|
+
* the boot budget AND one automatic retry have both been exhausted. Pure
|
|
604
|
+
* string builder (same shape as `formatForeignListenerError` /
|
|
605
|
+
* `formatCrossProjectError`): names what happened, the likely cause, where to
|
|
606
|
+
* look, and concrete next steps. Stack-agnostic — no app-type/framework
|
|
607
|
+
* assumptions.
|
|
608
|
+
*/
|
|
609
|
+
function formatServerStartTimeoutError(port, timeoutMs) {
|
|
610
|
+
const seconds = Math.round(timeoutMs / 1000);
|
|
611
|
+
return [
|
|
612
|
+
`codeyam-editor server did not bind port ${port} within ${seconds}s, ` +
|
|
613
|
+
`even after one automatic retry.`,
|
|
614
|
+
"",
|
|
615
|
+
"Likely cause: a slow cold boot (plan-claims git migration, PTY-broker " +
|
|
616
|
+
"respawn) — often a stale codeyam-editor-pty-broker whose socket is gone.",
|
|
617
|
+
"",
|
|
618
|
+
"Or: with 2+ projects registered this launch diverts to the Project " +
|
|
619
|
+
"Launcher (selector). An old binary that still discards --port binds the " +
|
|
620
|
+
"selector on a DIFFERENT ephemeral port, so the wait for this one times " +
|
|
621
|
+
"out — check .codeyam/logs/editor-server.out.log for a `Project Launcher " +
|
|
622
|
+
"running at` line and rebuild the editor.",
|
|
623
|
+
"",
|
|
624
|
+
"Where to look:",
|
|
625
|
+
" .codeyam/logs/editor-server.log",
|
|
626
|
+
" .codeyam/logs/editor-server.out.log",
|
|
627
|
+
"",
|
|
628
|
+
"What to try:",
|
|
629
|
+
" - Re-run the launcher.",
|
|
630
|
+
" - If a stale codeyam-editor-pty-broker is lingering, kill it, then re-run.",
|
|
631
|
+
" - If boots are consistently slow, raise the budget, e.g. " +
|
|
632
|
+
"CODEYAM_EDITOR_START_TIMEOUT_MS=60000.",
|
|
633
|
+
].join("\n");
|
|
634
|
+
}
|
|
635
|
+
|
|
575
636
|
/**
|
|
576
637
|
* Classify whatever is listening on `port` relative to `projectDir`.
|
|
577
638
|
* Returns one of:
|
|
578
639
|
* - `{ kind: "free" }` — nothing on the port
|
|
579
640
|
* - `{ kind: "foreign" }` — port held by a non-editor process
|
|
641
|
+
* - `{ kind: "launcher", identity }` — held by the Project Launcher (selector)
|
|
580
642
|
* - `{ kind: "cross-project", wantDir, haveDir, identity }`
|
|
581
643
|
* — held by another project's editor
|
|
582
644
|
* - `{ kind: "same-project", identity }` — held by THIS project's editor
|
|
@@ -596,6 +658,12 @@ async function classifyPortHolder(port, projectDir = process.cwd()) {
|
|
|
596
658
|
if (identity === null) {
|
|
597
659
|
return { kind: "foreign" };
|
|
598
660
|
}
|
|
661
|
+
// The Project Launcher (selector) holds the port in multi-project mode. It is
|
|
662
|
+
// codeyam-editor, not a foreign process and not a per-project editor, so it
|
|
663
|
+
// gets its own kind — the caller just re-opens it rather than refusing.
|
|
664
|
+
if (identity.role === "launcher") {
|
|
665
|
+
return { kind: "launcher", identity };
|
|
666
|
+
}
|
|
599
667
|
const wantDir = await canonicalisePath(projectDir);
|
|
600
668
|
const haveDir = await canonicalisePath(identity.projectDir);
|
|
601
669
|
if (wantDir !== haveDir) {
|
|
@@ -631,6 +699,76 @@ function isInteractiveLauncher(stream = process.stdout) {
|
|
|
631
699
|
return Boolean(stream && stream.isTTY);
|
|
632
700
|
}
|
|
633
701
|
|
|
702
|
+
/**
|
|
703
|
+
* Ask a yes/no question at the terminal and resolve `true` only on an
|
|
704
|
+
* affirmative `y`/`yes` (case-insensitive). A bare Enter, EOF, `n`, or any
|
|
705
|
+
* other text resolves `false` — the safe default for a destructive prompt.
|
|
706
|
+
*
|
|
707
|
+
* Mirrors `promptForInstanceName` in `npm/cloud.js`: readline reads from stdin
|
|
708
|
+
* and writes its prompt to stderr by default, so the question never pollutes a
|
|
709
|
+
* piped stdout. `input`/`output` are injectable so the helper is unit-testable
|
|
710
|
+
* by feeding a fake stream without a real TTY.
|
|
711
|
+
*/
|
|
712
|
+
function promptYesNo(question, { input = process.stdin, output = process.stderr } = {}) {
|
|
713
|
+
return new Promise((resolve) => {
|
|
714
|
+
const rl = readline.createInterface({ input, output });
|
|
715
|
+
rl.question(question, (ans) => {
|
|
716
|
+
rl.close();
|
|
717
|
+
const normalized = String(ans).trim().toLowerCase();
|
|
718
|
+
resolve(normalized === "y" || normalized === "yes");
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Decide what to do when `port` is held by a DIFFERENT project's editor server.
|
|
725
|
+
*
|
|
726
|
+
* Policy (shared by both launcher entry points so the wording and gating stay
|
|
727
|
+
* identical):
|
|
728
|
+
* - Non-interactive launcher (cloud container, agent-driven, piped/CI): print
|
|
729
|
+
* `formatCrossProjectError` and return `{ action: "abort" }` — never kill
|
|
730
|
+
* another project's server without a human at the keyboard, since the kill
|
|
731
|
+
* also tears down any Claude agent attached via the PTY broker.
|
|
732
|
+
* - Interactive launcher: print the full who/what/where, then ask whether to
|
|
733
|
+
* stop the other server and take over. On `y` → `stopServer(port)` and
|
|
734
|
+
* return `{ action: "took-over" }`. On anything else → `{ action: "declined" }`.
|
|
735
|
+
*
|
|
736
|
+
* Gates on `process.stdin.isTTY` (the INPUT stream — a human can only answer a
|
|
737
|
+
* prompt when stdin is a terminal), not stdout. The resolver prints the full
|
|
738
|
+
* `formatCrossProjectError` message in BOTH the abort and the interactive paths,
|
|
739
|
+
* so the caller only decides exit-vs-continue.
|
|
740
|
+
*
|
|
741
|
+
* Dependencies are injected (defaulting to the real implementations) so the
|
|
742
|
+
* resolver is unit-testable without a TTY or a live server — same style as
|
|
743
|
+
* `classifyPortHolder` / `resolveBootedServer`.
|
|
744
|
+
*/
|
|
745
|
+
async function resolveCrossProjectCollision(
|
|
746
|
+
port,
|
|
747
|
+
holder,
|
|
748
|
+
verb,
|
|
749
|
+
{
|
|
750
|
+
isInteractive = isInteractiveLauncher,
|
|
751
|
+
prompt = promptYesNo,
|
|
752
|
+
stop = stopServer,
|
|
753
|
+
} = {},
|
|
754
|
+
) {
|
|
755
|
+
console.error(formatCrossProjectError(port, holder, verb));
|
|
756
|
+
if (!isInteractive(process.stdin)) {
|
|
757
|
+
return { action: "abort" };
|
|
758
|
+
}
|
|
759
|
+
const pidNote = Number.isInteger(holder.identity && holder.identity.pid)
|
|
760
|
+
? ` (pid ${holder.identity.pid})`
|
|
761
|
+
: "";
|
|
762
|
+
const accepted = await prompt(
|
|
763
|
+
`\nKill that server${pidNote} and start this project's editor instead? [y/N] `,
|
|
764
|
+
);
|
|
765
|
+
if (!accepted) {
|
|
766
|
+
return { action: "declined" };
|
|
767
|
+
}
|
|
768
|
+
await stop(port);
|
|
769
|
+
return { action: "took-over" };
|
|
770
|
+
}
|
|
771
|
+
|
|
634
772
|
/**
|
|
635
773
|
* Spawn options for the editor server child. The server is spawned `detached:
|
|
636
774
|
* true` so it leads its OWN session + process group (setsid), decoupling its
|
|
@@ -742,22 +880,60 @@ function killChildProcess(child) {
|
|
|
742
880
|
}
|
|
743
881
|
}
|
|
744
882
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
883
|
+
/** Parse `lsof -t` stdout (one pid per line) into an array of integer pids. */
|
|
884
|
+
function parseLsofPids(stdout) {
|
|
885
|
+
return (stdout || "")
|
|
886
|
+
.split(/\s+/)
|
|
887
|
+
.map((line) => parseInt(line, 10))
|
|
888
|
+
.filter((pid) => Number.isInteger(pid));
|
|
889
|
+
}
|
|
750
890
|
|
|
751
|
-
|
|
891
|
+
/** Run `lsof` for the listeners on a TCP port. Returns the raw spawnSync
|
|
892
|
+
* result (`{ error?, stdout }`) so callers can distinguish a spawn failure
|
|
893
|
+
* from a genuinely empty result. */
|
|
894
|
+
function defaultRunLsof(port) {
|
|
895
|
+
return spawnSync("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], {
|
|
752
896
|
encoding: "utf8",
|
|
753
897
|
});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** Synchronous millisecond sleep — used to back off between lsof retries. */
|
|
901
|
+
function sleepSync(ms) {
|
|
902
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
903
|
+
}
|
|
754
904
|
|
|
755
|
-
|
|
905
|
+
/**
|
|
906
|
+
* Find the pids listening on a TCP port via `lsof`. Returns [] on Windows
|
|
907
|
+
* (lsof absent) or when no process is listening.
|
|
908
|
+
*
|
|
909
|
+
* A transient *spawn* failure (e.g. fork `EAGAIN` under heavy concurrent load —
|
|
910
|
+
* the cause of the flaky multi-runner test failure) is RETRIED with a short
|
|
911
|
+
* backoff rather than swallowed into "no listeners found". This matters because
|
|
912
|
+
* `findListeningPids` feeds `stopServer`: a one-off lsof spawn error must not be
|
|
913
|
+
* mistaken for "the server isn't running" and leave it alive. A clean run that
|
|
914
|
+
* legitimately reports no listeners returns [] on the first attempt (no error,
|
|
915
|
+
* so no retry) — free-port behavior and latency are unchanged.
|
|
916
|
+
*
|
|
917
|
+
* `runLsof` and `sleep` are injectable so the retry path is unit-testable
|
|
918
|
+
* without depending on real system load.
|
|
919
|
+
*/
|
|
920
|
+
function findListeningPids(port, runLsof = defaultRunLsof, sleep = sleepSync) {
|
|
921
|
+
// Process discovery on Windows is best-effort — `lsof` is not installed and
|
|
922
|
+
// a `netstat -ano` parser is a follow-up. The API-shutdown path and the
|
|
923
|
+
// PID-file path together stop the server cleanly in normal operation.
|
|
924
|
+
if (process.platform === "win32") return [];
|
|
756
925
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
926
|
+
const MAX_ATTEMPTS = 5;
|
|
927
|
+
const BACKOFF_MS = 50;
|
|
928
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
929
|
+
const result = runLsof(port);
|
|
930
|
+
// No spawn error → trust the result (empty stdout legitimately means "no
|
|
931
|
+
// listeners"; a non-empty stdout yields the pids).
|
|
932
|
+
if (!result.error) return parseLsofPids(result.stdout);
|
|
933
|
+
// Transient spawn failure — back off and retry, unless we're out of tries.
|
|
934
|
+
if (attempt < MAX_ATTEMPTS) sleep(BACKOFF_MS);
|
|
935
|
+
}
|
|
936
|
+
return [];
|
|
761
937
|
}
|
|
762
938
|
|
|
763
939
|
/** Stop the Rust backend server if it is running. */
|
|
@@ -819,10 +995,31 @@ async function ensureServer(port = 14199) {
|
|
|
819
995
|
console.error(formatForeignListenerError(port));
|
|
820
996
|
process.exit(1);
|
|
821
997
|
break;
|
|
822
|
-
case "cross-project":
|
|
823
|
-
|
|
998
|
+
case "cross-project": {
|
|
999
|
+
const outcome = await resolveCrossProjectCollision(port, holder, "attach");
|
|
1000
|
+
if (outcome.action === "took-over") {
|
|
1001
|
+
// `stopServer` already waited for the port to close, so fall through to
|
|
1002
|
+
// the fresh-boot path below — `spawnAndAwait` binds the now-free port.
|
|
1003
|
+
break;
|
|
1004
|
+
}
|
|
1005
|
+
// "abort" (non-TTY) or "declined" (user said no): the resolver already
|
|
1006
|
+
// printed the cross-project message. On decline, remind how to keep both.
|
|
1007
|
+
if (outcome.action === "declined") {
|
|
1008
|
+
console.error(
|
|
1009
|
+
"Leaving the existing server running. Change controlPort in " +
|
|
1010
|
+
".codeyam/editor.local.json to run both, e.g.:\n" +
|
|
1011
|
+
" codeyam-editor editor config-override proxy.controlPort 14399",
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
824
1014
|
process.exit(1);
|
|
825
1015
|
break;
|
|
1016
|
+
}
|
|
1017
|
+
case "launcher":
|
|
1018
|
+
// The selector is already up on this port (multi-project mode). Treat it
|
|
1019
|
+
// like an already-running same-project server: don't refuse, don't
|
|
1020
|
+
// double-spawn — just let the caller re-open the port.
|
|
1021
|
+
console.error(`Project selector already running on port ${port}`);
|
|
1022
|
+
return null;
|
|
826
1023
|
case "same-project":
|
|
827
1024
|
console.error(`Server already running on port ${port}`);
|
|
828
1025
|
return null;
|
|
@@ -832,6 +1029,12 @@ async function ensureServer(port = 14199) {
|
|
|
832
1029
|
}
|
|
833
1030
|
|
|
834
1031
|
const binary = ensureBinary();
|
|
1032
|
+
const startTimeoutMs = resolveStartTimeoutMs();
|
|
1033
|
+
|
|
1034
|
+
// Spawn a fresh server child and race its boot against `timeoutMs`. Factored
|
|
1035
|
+
// into a closure so the auto-retry path re-runs an identical spawn with a
|
|
1036
|
+
// longer budget without duplicating the spawn options.
|
|
1037
|
+
//
|
|
835
1038
|
// UI bundle is embedded in the Rust binary; CODEYAM_EDITOR_UI_DIR is
|
|
836
1039
|
// now an explicit dev-override only.
|
|
837
1040
|
//
|
|
@@ -843,43 +1046,113 @@ async function ensureServer(port = 14199) {
|
|
|
843
1046
|
// `.codeyam/logs/editor-server.out.log` (the Rust server also writes its own
|
|
844
1047
|
// editor-server.log). `unref()` keeps the detached child from pinning the
|
|
845
1048
|
// launcher's event loop alive on its own.
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
1049
|
+
const spawnAndAwait = async (timeoutMs) => {
|
|
1050
|
+
const logFd = openServerOutputLog(process.cwd());
|
|
1051
|
+
const child = spawn(
|
|
1052
|
+
binary,
|
|
1053
|
+
["start", "--no-open", "--port", String(port)],
|
|
1054
|
+
buildServerSpawnOptions({ cwd: process.cwd(), env: process.env, logFd }),
|
|
1055
|
+
);
|
|
1056
|
+
if (logFd !== null) {
|
|
1057
|
+
try {
|
|
1058
|
+
fs.closeSync(logFd);
|
|
1059
|
+
} catch {
|
|
1060
|
+
/* child holds its own dup of the fd; the launcher's copy is no longer needed */
|
|
1061
|
+
}
|
|
857
1062
|
}
|
|
1063
|
+
child.unref();
|
|
1064
|
+
const race = await awaitServerReadyOrChildExit(child, port, (p) =>
|
|
1065
|
+
waitForPort(p, timeoutMs),
|
|
1066
|
+
);
|
|
1067
|
+
return { child, ...race };
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
// Orchestrate the boot (re-check-before-kill + one auto-retry) with the real
|
|
1071
|
+
// effects injected, then map the discriminated result onto the process-exit
|
|
1072
|
+
// policy. Keeping the orchestration in `resolveBootedServer` (pure control
|
|
1073
|
+
// flow over injected effects) is what makes the re-check guard and the retry
|
|
1074
|
+
// unit-testable without spawning the real binary or stubbing process.exit.
|
|
1075
|
+
const result = await resolveBootedServer({
|
|
1076
|
+
port,
|
|
1077
|
+
startTimeoutMs,
|
|
1078
|
+
spawnAndAwait,
|
|
1079
|
+
isPortInUseFn: isPortInUse,
|
|
1080
|
+
});
|
|
1081
|
+
switch (result.kind) {
|
|
1082
|
+
case "ready":
|
|
1083
|
+
return result.child;
|
|
1084
|
+
case "early-exit": {
|
|
1085
|
+
// A genuine early exit (preflight bail) is always a hard fail — even on
|
|
1086
|
+
// code 0: the caller needs to know the server isn't running.
|
|
1087
|
+
const { code, signal } = result.exitedBeforeBind ?? { code: null, signal: null };
|
|
1088
|
+
const detail = signal != null ? `signal ${signal}` : `code ${code}`;
|
|
1089
|
+
console.error(
|
|
1090
|
+
`codeyam-editor server exited (${detail}) before binding port ${port}. ` +
|
|
1091
|
+
"Likely a preflight bail; see .codeyam/logs/editor-server.out.log " +
|
|
1092
|
+
"and .codeyam/logs/editor-server.log.",
|
|
1093
|
+
);
|
|
1094
|
+
process.exit(code != null && code !== 0 ? code : 1);
|
|
1095
|
+
break;
|
|
1096
|
+
}
|
|
1097
|
+
case "timeout-exhausted":
|
|
1098
|
+
default:
|
|
1099
|
+
console.error(formatServerStartTimeoutError(port, result.retryTimeoutMs));
|
|
1100
|
+
process.exit(1);
|
|
858
1101
|
}
|
|
859
|
-
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Boot orchestration for `ensureServer`, isolated from the process-exit policy
|
|
1106
|
+
* and from the spawn/port effects so the re-check-before-kill guard and the
|
|
1107
|
+
* single auto-retry are unit-testable. Pure control flow over two injected
|
|
1108
|
+
* effects:
|
|
1109
|
+
* - `spawnAndAwait(timeoutMs)` → `{ child, outcome, exitedBeforeBind }` —
|
|
1110
|
+
* one boot attempt racing the port against the child exit.
|
|
1111
|
+
* - `isPortInUseFn(port)` → bool — the final re-check probe.
|
|
1112
|
+
*
|
|
1113
|
+
* Returns a discriminated result the caller maps to an action:
|
|
1114
|
+
* - `{ kind: "ready", child }` — server bound; keep this child.
|
|
1115
|
+
* - `{ kind: "early-exit", exitedBeforeBind }` — child exited before bind
|
|
1116
|
+
* (preflight bail); a hard fail, never retried.
|
|
1117
|
+
* - `{ kind: "timeout-exhausted", retryTimeoutMs }` — both the budget and a
|
|
1118
|
+
* longer-budget retry missed; give up with actionable guidance.
|
|
1119
|
+
*
|
|
1120
|
+
* The destructive `child.kill()` only fires after a re-check confirms the port
|
|
1121
|
+
* is NOT listening — a server that bound a hair after the deadline is kept, not
|
|
1122
|
+
* torn down (the reported race). The retry runs at most once.
|
|
1123
|
+
*/
|
|
1124
|
+
async function resolveBootedServer({ port, startTimeoutMs, spawnAndAwait, isPortInUseFn }) {
|
|
1125
|
+
// One boot attempt, with the re-check folded in: a post-deadline bind is
|
|
1126
|
+
// reported as "ready" rather than "timeout" so the caller never kills it.
|
|
1127
|
+
const attempt = async (timeoutMs) => {
|
|
1128
|
+
const { child, outcome, exitedBeforeBind } = await spawnAndAwait(timeoutMs);
|
|
1129
|
+
if (outcome === "ready") return { kind: "ready", child };
|
|
1130
|
+
if (outcome === "exited" || (outcome === "timeout" && exitedBeforeBind !== null)) {
|
|
1131
|
+
return { kind: "early-exit", exitedBeforeBind };
|
|
1132
|
+
}
|
|
1133
|
+
// Genuine timeout — re-check before reporting a miss.
|
|
1134
|
+
if (await isPortInUseFn(port)) return { kind: "ready", child };
|
|
1135
|
+
return { kind: "timeout", child };
|
|
1136
|
+
};
|
|
860
1137
|
|
|
861
|
-
const
|
|
1138
|
+
const first = await attempt(startTimeoutMs);
|
|
1139
|
+
if (first.kind !== "timeout") return first;
|
|
862
1140
|
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
// needs to know the server isn't running.
|
|
873
|
-
process.exit(code != null && code !== 0 ? code : 1);
|
|
874
|
-
}
|
|
1141
|
+
// The boot was just slow (cold caches, broker respawn). Kill the stalled
|
|
1142
|
+
// attempt and auto-retry ONCE with a longer budget — a single retry, not a
|
|
1143
|
+
// loop, so a truly broken server isn't masked behind endless retries.
|
|
1144
|
+
const retryTimeoutMs = startTimeoutMs * 2;
|
|
1145
|
+
console.error(
|
|
1146
|
+
`Server did not bind port ${port} within ${Math.round(startTimeoutMs / 1000)}s; ` +
|
|
1147
|
+
`retrying once with a longer budget...`,
|
|
1148
|
+
);
|
|
1149
|
+
first.child.kill();
|
|
875
1150
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
child.kill();
|
|
879
|
-
process.exit(1);
|
|
880
|
-
}
|
|
1151
|
+
const second = await attempt(retryTimeoutMs);
|
|
1152
|
+
if (second.kind !== "timeout") return second;
|
|
881
1153
|
|
|
882
|
-
|
|
1154
|
+
second.child.kill();
|
|
1155
|
+
return { kind: "timeout-exhausted", retryTimeoutMs };
|
|
883
1156
|
}
|
|
884
1157
|
|
|
885
1158
|
/**
|
|
@@ -934,13 +1207,19 @@ module.exports = {
|
|
|
934
1207
|
classifyPortHolder,
|
|
935
1208
|
formatForeignListenerError,
|
|
936
1209
|
formatCrossProjectError,
|
|
1210
|
+
formatServerStartTimeoutError,
|
|
1211
|
+
resolveStartTimeoutMs,
|
|
937
1212
|
tryKillPid,
|
|
938
1213
|
findListeningPids,
|
|
1214
|
+
parseLsofPids,
|
|
939
1215
|
stopServer,
|
|
940
1216
|
restartServer,
|
|
941
1217
|
ensureServer,
|
|
1218
|
+
resolveBootedServer,
|
|
942
1219
|
awaitServerReadyOrChildExit,
|
|
943
1220
|
isInteractiveLauncher,
|
|
1221
|
+
promptYesNo,
|
|
1222
|
+
resolveCrossProjectCollision,
|
|
944
1223
|
buildServerSpawnOptions,
|
|
945
1224
|
openServerOutputLog,
|
|
946
1225
|
formatLauncherSignalRecord,
|
package/npm/vm-urls.js
CHANGED
|
@@ -68,25 +68,82 @@ function normalizeVmNumber(n) {
|
|
|
68
68
|
return num;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// A single lowercase DNS label: 1–63 chars of letters/digits/hyphen, no leading
|
|
72
|
+
// or trailing hyphen, NO dots. A custom slug becomes the leftmost label of a
|
|
73
|
+
// `<slug>.<surface>.<domain>` host the wildcard cert (`*.editor.<domain>`) and
|
|
74
|
+
// the wildcard `A` records already cover, so it must be exactly one label —
|
|
75
|
+
// `my.slug` would escape the wildcard and resolve nowhere.
|
|
76
|
+
const SLUG_RE = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
|
|
77
|
+
|
|
78
|
+
// Slugs that would collide with a fixed fleet surface. `dashboard.<domain>` is
|
|
79
|
+
// the operator dashboard's own host; `editor` / `preview` are the per-VM surface
|
|
80
|
+
// labels in `<slug>.editor.<domain>` — a slug of `editor` would mint the
|
|
81
|
+
// nonsensical `editor.editor.<domain>` and shadow the surface segment.
|
|
82
|
+
const RESERVED_SLUGS = Object.freeze(['dashboard', 'editor', 'preview']);
|
|
83
|
+
|
|
84
|
+
/** Validate + normalize a custom VM slug into a single lowercase DNS label.
|
|
85
|
+
* A bad slug would bake a dead host into the URL map or shadow another VM's
|
|
86
|
+
* routing, so we reject it loudly at the derivation boundary — the same posture
|
|
87
|
+
* as `resolveFleetDomain` / `normalizeVmNumber`. Rejects: anything that isn't a
|
|
88
|
+
* single label (dots, leading/trailing hyphen, >63 chars, empty); the reserved
|
|
89
|
+
* words `dashboard` / `editor` / `preview`; and the `vm-<N>` shape, which would
|
|
90
|
+
* collide with the numeric scheme. Returns the trimmed/lowercased slug. */
|
|
91
|
+
function normalizeVmSlug(slug) {
|
|
92
|
+
if (slug == null) throw new Error('VM slug is required');
|
|
93
|
+
const s = String(slug).trim().toLowerCase();
|
|
94
|
+
if (!SLUG_RE.test(s)) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`VM slug "${slug}" is not a valid single DNS label ` +
|
|
97
|
+
'(lowercase letters, digits, hyphens; no dots; 1–63 chars; ' +
|
|
98
|
+
'no leading/trailing hyphen)',
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (RESERVED_SLUGS.includes(s)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`VM slug "${s}" is reserved (${RESERVED_SLUGS.join(', ')})`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (/^vm-\d+$/.test(s)) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`VM slug "${s}" collides with the numeric vm-<N> scheme — pick a name`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return s;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The leftmost hostname label for a VM: the validated `slug` when supplied,
|
|
115
|
+
* else the numeric `vm-<N>` default. Single-sources the "slug overrides the
|
|
116
|
+
* number, otherwise fall back" rule every hostname derivation shares. */
|
|
117
|
+
function vmLabel(n, slug) {
|
|
118
|
+
if (slug != null && String(slug).trim() !== '') return normalizeVmSlug(slug);
|
|
119
|
+
return `vm-${normalizeVmNumber(n)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
71
122
|
/** The bare per-VM hostnames (no scheme), derived from the VM number + domain.
|
|
72
123
|
* `editor` fronts the in-VM editor control API; `preview` fronts the VM's dev
|
|
73
124
|
* server (the Live Preview). These are what the named-tunnel ingress rules and
|
|
74
|
-
* the DNS CNAME records are keyed on — see named-tunnel-config.sh.
|
|
75
|
-
|
|
76
|
-
|
|
125
|
+
* the DNS CNAME records are keyed on — see named-tunnel-config.sh.
|
|
126
|
+
*
|
|
127
|
+
* An optional `slug` overrides the leftmost `vm-<N>` label with a human-readable
|
|
128
|
+
* name (`<slug>.editor.<domain>`); with no slug the output is byte-for-byte the
|
|
129
|
+
* numeric scheme, so the change is a no-op for every existing caller. */
|
|
130
|
+
function vmHostnames(n, domain, slug) {
|
|
131
|
+
const label = vmLabel(n, slug); // validates n (and slug, when present)
|
|
77
132
|
if (!domain) throw new Error('vmHostnames requires a fleet domain');
|
|
78
133
|
return {
|
|
79
|
-
editor:
|
|
80
|
-
preview:
|
|
134
|
+
editor: `${label}.editor.${domain}`,
|
|
135
|
+
preview: `${label}.preview.${domain}`,
|
|
81
136
|
};
|
|
82
137
|
}
|
|
83
138
|
|
|
84
139
|
/** The per-VM public HTTPS URLs, derived from the VM number + domain. The
|
|
85
140
|
* deterministic replacement for `cloudflared-urls.json[n]` /
|
|
86
141
|
* `cloudflared-urls.json[n-preview]` — same shape the dashboard surfaced from
|
|
87
|
-
* the monitor's recordings, now with no recording, probing, or rotation.
|
|
88
|
-
|
|
89
|
-
|
|
142
|
+
* the monitor's recordings, now with no recording, probing, or rotation. An
|
|
143
|
+
* optional `slug` rides through to `vmHostnames` so the URLs carry the custom
|
|
144
|
+
* hostname; absent, they are unchanged. */
|
|
145
|
+
function vmUrls(n, domain, slug) {
|
|
146
|
+
const host = vmHostnames(n, domain, slug);
|
|
90
147
|
return {
|
|
91
148
|
editor: `https://${host.editor}`,
|
|
92
149
|
preview: `https://${host.preview}`,
|
|
@@ -108,8 +165,12 @@ function dashboardUrl(domain) {
|
|
|
108
165
|
|
|
109
166
|
module.exports = {
|
|
110
167
|
FLEET_DOMAIN_ENV,
|
|
168
|
+
SLUG_RE,
|
|
169
|
+
RESERVED_SLUGS,
|
|
111
170
|
resolveFleetDomain,
|
|
112
171
|
normalizeVmNumber,
|
|
172
|
+
normalizeVmSlug,
|
|
173
|
+
vmLabel,
|
|
113
174
|
vmHostnames,
|
|
114
175
|
vmUrls,
|
|
115
176
|
dashboardHostname,
|
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.5",
|
|
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.5",
|
|
13
|
+
"@codeyam-editor/codeyam-editor-darwin-x64": "0.1.5",
|
|
14
|
+
"@codeyam-editor/codeyam-editor-linux-x64": "0.1.5",
|
|
15
|
+
"@codeyam-editor/codeyam-editor-win32-x64": "0.1.5"
|
|
16
16
|
},
|
|
17
17
|
"keywords": [
|
|
18
18
|
"codeyam",
|