@codeyam-editor/codeyam-editor 0.1.13-staging.g1b6c798 → 0.1.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.
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+
3
+ // Detect a fleet VM whose app port drifted away from the port its container
4
+ // publishes on the host.
5
+ //
6
+ // A VM's compose render publishes the app port resolved ONCE at bootstrap
7
+ // (`apps[0].port ?? port ?? 5173`). When the project later moves its app port
8
+ // in-repo, the editor's preview proxy follows the new port while the host keeps
9
+ // publishing the old one: docker-proxy accepts the load balancer's connection
10
+ // and immediately resets it, and every in-container probe reads green because
11
+ // the editor probes its own proxy on the new port. The only comparison that can
12
+ // see it is DECLARED (editor.json + editor.local.json) against PUBLISHED (what
13
+ // the running container actually maps on the host) — that is what this module
14
+ // does. It never repairs: changing the publication means recreating the
15
+ // container, which drops every live agent session.
16
+ //
17
+ // Pure helpers plus an injectable collector, so the reconcile and `vm-ingress.sh
18
+ // status` share one rule and the unit tests never reach for SSH.
19
+
20
+ /** The editor's control port. Pinned by contract, so on a VM's editor container
21
+ * the app surface is the OTHER published mapping — the same disambiguation
22
+ * `rcc_recover_ports` (scripts/lib/render-cloud-compose.sh) makes. */
23
+ const EDITOR_CONTROL_PORT = 14199;
24
+
25
+ const PORT_DRIFT_RUNBOOK = "docs/preview-port-drift.md";
26
+
27
+ function isValidPort(n) {
28
+ return Number.isInteger(n) && n > 0 && n < 65536;
29
+ }
30
+
31
+ /** Remote command that prints the editor container's published port list
32
+ * (`docker ps` `{{.Ports}}`), selecting the container by the control port it
33
+ * publishes rather than by a name, so a client VM whose compose project is not
34
+ * named `codeyam-editor` resolves the same way. Reads the LIVE bindings — no
35
+ * compose-file path to guess. Pure string builder. */
36
+ function buildPublishedAppPortReadCommand() {
37
+ return `docker ps --filter publish=${EDITOR_CONTROL_PORT} --format '{{.Ports}}' 2>/dev/null`;
38
+ }
39
+
40
+ /** Parse that command's output into the published app port, or null.
41
+ *
42
+ * `{{.Ports}}` reads like `0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp,
43
+ * 10.0.0.5:14199->14199/tcp`. The app port is the single host port that is not
44
+ * the control port. Anything else — no container, more than one container, no
45
+ * control-port mapping, zero or several candidate app ports — returns null:
46
+ * an unreadable publication must never be reported as a mismatch. */
47
+ function parsePublishedAppPort(stdout) {
48
+ if (typeof stdout !== "string") return null;
49
+ const lines = stdout
50
+ .split("\n")
51
+ .map((l) => l.trim())
52
+ .filter(Boolean);
53
+ if (lines.length !== 1) return null;
54
+ const hostPorts = new Set();
55
+ for (const m of lines[0].matchAll(/:(\d+)->\d+\/tcp/g)) hostPorts.add(Number(m[1]));
56
+ if (!hostPorts.delete(EDITOR_CONTROL_PORT)) return null;
57
+ if (hostPorts.size !== 1) return null;
58
+ const [port] = hostPorts;
59
+ return isValidPort(port) ? port : null;
60
+ }
61
+
62
+ /** Parse `cloud-session-idle.sh` stdout into `idle` | `active` | null. Null
63
+ * (unknown) is deliberately NOT idle — the same asymmetry that script and
64
+ * `converge-vm.sh --converge-env` honour. */
65
+ function parseIdleVerdict(stdout) {
66
+ if (typeof stdout !== "string") return null;
67
+ const v = stdout.trim().split("\n").pop();
68
+ return v === "idle" || v === "active" ? v : null;
69
+ }
70
+
71
+ /** The remediations that apply to one drifted VM, cheapest first.
72
+ *
73
+ * The session-preserving pin always applies. The re-publish (recreate the
74
+ * container so the host publishes the declared port) is named ONLY when the
75
+ * VM is known idle, because it drops the PTY broker and every live session. */
76
+ function appPortDriftRemediations({ vmNumber, declared, published, idle }) {
77
+ const out = [
78
+ {
79
+ kind: "pin-to-published",
80
+ preservesSessions: true,
81
+ text:
82
+ `on VM-${vmNumber}, run \`codeyam-editor editor config-override apps.0.port ${published}\` ` +
83
+ `in the project — pins the app back to the published port with an in-place proxy rebind, no restart`,
84
+ },
85
+ ];
86
+ if (idle === "idle") {
87
+ out.push({
88
+ kind: "republish-declared",
89
+ preservesSessions: false,
90
+ text:
91
+ `VM-${vmNumber} is idle, so the container can instead be recreated to publish ${declared}: ` +
92
+ `change the app-port mapping in its docker-compose.cloud.yml from ${published} to ${declared} ` +
93
+ `and recreate the editor container (see ${PORT_DRIFT_RUNBOOK})`,
94
+ });
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** Compare one VM's declared app port against its published host port.
100
+ *
101
+ * `deps.readDeclaredPort` / `deps.readPublishedPort` return a port or null;
102
+ * `deps.readIdleVerdict` (optional) returns `idle` | `active` | null and is
103
+ * only consulted once a drift is found. Returns `[]` when the two agree or
104
+ * when EITHER cannot be read — no 5173 fallback is ever used for the
105
+ * comparison — else a one-element findings array. The published read is
106
+ * skipped when the declared port is unreadable, so a VM with no scaffolded
107
+ * project costs one probe, not two. */
108
+ async function collectAppPortDrift(vm, deps) {
109
+ const { vmNumber, slug = null } = vm || {};
110
+ const declared = await deps.readDeclaredPort();
111
+ if (!isValidPort(declared)) return [];
112
+ const published = await deps.readPublishedPort();
113
+ if (!isValidPort(published) || published === declared) return [];
114
+ const idle = deps.readIdleVerdict ? await deps.readIdleVerdict() : null;
115
+ return [
116
+ {
117
+ vmNumber,
118
+ slug,
119
+ declared,
120
+ published,
121
+ idle,
122
+ remediations: appPortDriftRemediations({ vmNumber, declared, published, idle }),
123
+ },
124
+ ];
125
+ }
126
+
127
+ /** Render one finding as the operator-facing warning line(s). */
128
+ function formatAppPortDriftWarning(finding) {
129
+ const label = finding.slug || `vm-${finding.vmNumber}`;
130
+ const lines = [
131
+ ` VM-${finding.vmNumber} ${label}: PORT DRIFT — the project declares app port ` +
132
+ `${finding.declared} but the container publishes ${finding.published}, so the ` +
133
+ `load balancer cannot reach the Live Preview (in-container probes still read healthy).`,
134
+ ];
135
+ for (const r of finding.remediations) lines.push(` fix: ${r.text}`);
136
+ if (finding.idle !== "idle") {
137
+ lines.push(
138
+ ` (recreating the container to publish ${finding.declared} is not offered: ` +
139
+ `the VM is ${finding.idle || "not confirmed idle"} and a recreate drops live sessions)`,
140
+ );
141
+ }
142
+ return lines.join("\n");
143
+ }
144
+
145
+ /** Render the single machine-readable field `vm-ingress.sh status` prints.
146
+ * Kept separate from the `verdict=` line on purpose: the verdict drives the
147
+ * reconcile's re-`up` loop, and re-`up`ping cannot fix a port-publication
148
+ * drift. */
149
+ function formatPortDriftStatusLine(findings, { declared = null, published = null } = {}) {
150
+ if (findings.length) {
151
+ const f = findings[0];
152
+ return `port-drift=yes declared=${f.declared} published=${f.published}`;
153
+ }
154
+ if (isValidPort(declared) && isValidPort(published)) {
155
+ return `port-drift=no declared=${declared} published=${published}`;
156
+ }
157
+ return "port-drift=unknown";
158
+ }
159
+
160
+ module.exports = {
161
+ EDITOR_CONTROL_PORT,
162
+ buildPublishedAppPortReadCommand,
163
+ parsePublishedAppPort,
164
+ parseIdleVerdict,
165
+ appPortDriftRemediations,
166
+ collectAppPortDrift,
167
+ formatAppPortDriftWarning,
168
+ formatPortDriftStatusLine,
169
+ };
package/npm/cloud.js CHANGED
@@ -105,6 +105,17 @@ const {
105
105
  firewallNeedsConverge,
106
106
  firewallAdmitsPort,
107
107
  } = require("./ingress-invoke");
108
+ // Declared-vs-published app-port comparison (pure; unit-tested in
109
+ // app-port-drift.test.js). The SSH readers that feed it live below, beside
110
+ // readVmAppPort.
111
+ const {
112
+ buildPublishedAppPortReadCommand,
113
+ parsePublishedAppPort,
114
+ parseIdleVerdict,
115
+ collectAppPortDrift,
116
+ formatAppPortDriftWarning,
117
+ formatPortDriftStatusLine,
118
+ } = require("./app-port-drift");
108
119
  // Shared fleet-roster writer/reader. `cloud:up` rosters the VM it just
109
120
  // provisioned so the dashboard, the cloudflared-monitor tunnel publisher, and
110
121
  // the watchdog see it within one poll cycle — without a manual fleet-vms.json
@@ -3031,32 +3042,66 @@ function buildVmAppPortReadCommand(repoName) {
3031
3042
  * `VITE_PORT` is genuinely correct there (it is what `resolved_port()` lands
3032
3043
  * on) and stays quiet. */
3033
3044
  async function readVmAppPort(cfg) {
3034
- const repoName = resolveProject(cfg).name;
3035
- const read = runGcloud(
3036
- buildGcloudArgs("ssh-probe", cfg, {
3037
- command: buildVmAppPortReadCommand(repoName),
3038
- }),
3039
- { stdio: ["ignore", "pipe", "ignore"] },
3040
- );
3041
- if (read.status !== 0 || typeof read.stdout !== "string") {
3045
+ const { port, failure } = await probeVmAppPort(cfg);
3046
+ if (failure && failure.kind === "ssh") {
3042
3047
  console.error(
3043
- `[cloud] WARNING: could not read the VM's app port over SSH — ` +
3048
+ `[cloud] WARNING: ${failure.what} — ` +
3044
3049
  `falling back to ${VITE_PORT}. If the app does not serve on ` +
3045
3050
  `${VITE_PORT}, the laptop port-forward targets the wrong port and the ` +
3046
3051
  `Live Preview will show "localhost refused to connect".`,
3047
3052
  );
3048
- return VITE_PORT;
3049
- }
3050
- if (read.stdout.includes(VM_APP_PORT_NO_CONFIG)) {
3053
+ } else if (failure) {
3051
3054
  console.error(
3052
- `[cloud] WARNING: no .codeyam/editor.json found under ~/projects on the ` +
3053
- `VM (looked for '${repoName}', then any project dir) — falling back to ` +
3055
+ `[cloud] WARNING: ${failure.what} — falling back to ` +
3054
3056
  `${VITE_PORT}. If the project is scaffolded under a different name and ` +
3055
3057
  `serves on another port, the port-forward targets the wrong port and ` +
3056
3058
  `the Live Preview will show "localhost refused to connect".`,
3057
3059
  );
3058
- return VITE_PORT;
3059
3060
  }
3061
+ return port == null ? VITE_PORT : port;
3062
+ }
3063
+
3064
+ /** The probe behind `readVmAppPort`, without the fallback: resolves to
3065
+ * `{ port, failure: null }` when the VM's config was read, or
3066
+ * `{ port: null, failure: { kind: "ssh" | "no-config", what } }` when it was
3067
+ * not. Callers that must not act on a guess — the ingress reconcile, where a
3068
+ * 5173 fallback would wire a preview NEG endpoint to a port nothing publishes —
3069
+ * use this to tell a resolved port from a fallback. Prints nothing; `what` is
3070
+ * the human description of the miss for the caller's own warning. */
3071
+ async function probeVmAppPort(cfg) {
3072
+ const repoName = resolveProject(cfg).name;
3073
+ const read = runGcloud(
3074
+ buildGcloudArgs("ssh-probe", cfg, {
3075
+ command: buildVmAppPortReadCommand(repoName),
3076
+ }),
3077
+ { stdio: ["ignore", "pipe", "ignore"] },
3078
+ );
3079
+ if (read.status !== 0 || typeof read.stdout !== "string") {
3080
+ return {
3081
+ port: null,
3082
+ failure: { kind: "ssh", what: "could not read the VM's app port over SSH" },
3083
+ };
3084
+ }
3085
+ const { port, miss } = parseVmDeclaredAppPort(read.stdout);
3086
+ if (miss === "no-config") {
3087
+ return {
3088
+ port: null,
3089
+ failure: {
3090
+ kind: "no-config",
3091
+ what:
3092
+ `no .codeyam/editor.json found under ~/projects on the ` +
3093
+ `VM (looked for '${repoName}', then any project dir)`,
3094
+ },
3095
+ };
3096
+ }
3097
+ return { port, failure: null };
3098
+ }
3099
+
3100
+ /** Resolve `buildVmAppPortReadCommand`'s output into `{ port, miss }` — shared
3101
+ * by `probeVmAppPort` and the port-drift probe, so both resolve the declared
3102
+ * port identically. A no-config VM is a miss with a null port, never 5173. */
3103
+ function parseVmDeclaredAppPort(stdout) {
3104
+ if (stdout.includes(VM_APP_PORT_NO_CONFIG)) return { port: null, miss: "no-config" };
3060
3105
  const parse = (s) => {
3061
3106
  try {
3062
3107
  const v = JSON.parse(s);
@@ -3065,8 +3110,116 @@ async function readVmAppPort(cfg) {
3065
3110
  return {};
3066
3111
  }
3067
3112
  };
3068
- const [baseJson = "", localJson = ""] = read.stdout.split("<<<LOCAL>>>");
3069
- return resolveConfiguredAppPort(parse(baseJson), parse(localJson));
3113
+ const [baseJson = "", localJson = ""] = stdout.split("<<<LOCAL>>>");
3114
+ return { port: resolveConfiguredAppPort(parse(baseJson), parse(localJson)), miss: null };
3115
+ }
3116
+
3117
+ /** Separates the declared-port read from the `docker ps` read in the combined
3118
+ * probe below. */
3119
+ const VM_PUBLISHED_PORT_MARKER = "<<<PUBLISHED>>>";
3120
+
3121
+ /** Read the declared app port AND the host port the VM's editor container
3122
+ * publishes (live `docker ps` bindings) in ONE SSH round-trip, so the roster
3123
+ * pass costs one extra probe per VM. Either side is null when unreadable. */
3124
+ function readVmAppPortsForDrift(cfg) {
3125
+ const read = runGcloud(
3126
+ buildGcloudArgs("ssh-probe", cfg, {
3127
+ command:
3128
+ `${buildVmAppPortReadCommand(resolveProject(cfg).name)}; ` +
3129
+ `echo '${VM_PUBLISHED_PORT_MARKER}'; ${buildPublishedAppPortReadCommand()}`,
3130
+ }),
3131
+ { stdio: ["ignore", "pipe", "ignore"] },
3132
+ );
3133
+ if (read.status !== 0) return { declared: null, published: null };
3134
+ return parseVmAppPortsForDrift(read.stdout);
3135
+ }
3136
+
3137
+ /** Split the combined probe's output into `{ declared, published }`. Output
3138
+ * without the marker — an SSH session that exited 0 having printed nothing, a
3139
+ * truncated read — is unreadable on BOTH sides. Resolving its empty config
3140
+ * half would land on the 5173 default and could report a drift that does not
3141
+ * exist. */
3142
+ function parseVmAppPortsForDrift(stdout) {
3143
+ if (typeof stdout !== "string" || !stdout.includes(VM_PUBLISHED_PORT_MARKER)) {
3144
+ return { declared: null, published: null };
3145
+ }
3146
+ const [declaredOut, publishedOut] = stdout.split(VM_PUBLISHED_PORT_MARKER);
3147
+ return {
3148
+ declared: declaredOut.trim() ? parseVmDeclaredAppPort(declaredOut).port : null,
3149
+ published: parsePublishedAppPort(publishedOut),
3150
+ };
3151
+ }
3152
+
3153
+ /** Read the VM's `cloud-session-idle.sh` verdict (`idle` | `active` | null).
3154
+ * Unknown — including a VM whose checkout predates the script — is null, and
3155
+ * null is never treated as idle. */
3156
+ function readVmIdleVerdict(cfg) {
3157
+ const read = runGcloud(
3158
+ buildGcloudArgs("ssh-probe", cfg, {
3159
+ command:
3160
+ `s="$HOME/codeyam-editor/scripts/cloud-session-idle.sh"; ` +
3161
+ `if [ -r "$s" ]; then bash "$s" 2>/dev/null; fi; true`,
3162
+ }),
3163
+ { stdio: ["ignore", "pipe", "ignore"] },
3164
+ );
3165
+ return read.status === 0 ? parseIdleVerdict(read.stdout) : null;
3166
+ }
3167
+
3168
+ /** Compare one VM's declared app port against its published host port over the
3169
+ * SSH probe. Returns the findings plus both raw reads (for the status line).
3170
+ * See npm/app-port-drift.js for the rule. */
3171
+ async function collectVmAppPortDrift(cfg, vmNumber, slug) {
3172
+ const { declared, published } = readVmAppPortsForDrift(cfg);
3173
+ const findings = await collectAppPortDrift(
3174
+ { vmNumber, slug },
3175
+ {
3176
+ readDeclaredPort: async () => declared,
3177
+ readPublishedPort: async () => published,
3178
+ readIdleVerdict: async () => readVmIdleVerdict(cfg),
3179
+ },
3180
+ );
3181
+ return { findings, declared, published };
3182
+ }
3183
+
3184
+ /** Port-drift pass over the whole roster, on the periodic reconcile that already
3185
+ * walks it. Reports, never repairs (the fix recreates the container or changes
3186
+ * the project's port — both the operator's call). Per-VM failures are logged
3187
+ * and swallowed, like the preview-origin heal beside it. */
3188
+ async function reportAppPortDriftAcrossRoster(cfg, roster, deps = {}) {
3189
+ const collect = deps.collectVmAppPortDrift || collectVmAppPortDrift;
3190
+ const drifted = [];
3191
+ for (const n of roster) {
3192
+ const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${n}` };
3193
+ try {
3194
+ const { findings } = await collect(perVmCfg, n, slugFor(n));
3195
+ for (const f of findings) {
3196
+ console.error(formatAppPortDriftWarning(f));
3197
+ drifted.push(f);
3198
+ }
3199
+ } catch (e) {
3200
+ console.error(` VM-${n} ${slugFor(n) || `vm-${n}`}: port-drift check skipped (${e && e.message})`);
3201
+ }
3202
+ }
3203
+ return drifted;
3204
+ }
3205
+
3206
+ /** `npm run cloud:app-port-drift -- --instance-name <name>` — one VM's
3207
+ * declared-vs-published comparison as the single `port-drift=` line
3208
+ * `vm-ingress.sh status` appends, plus the human warning on stderr. */
3209
+ async function cmdAppPortDrift(args, projectDir = rootDir) {
3210
+ const flags = parseFlags(args);
3211
+ const cfg = { ...(loadCloudConfig(projectDir) || {}), ...flags };
3212
+ const vmNumber = resolveFleetVmNumber(cfg.instanceName || "");
3213
+ if (vmNumber == null) {
3214
+ throw new Error("cloud:app-port-drift requires --instance-name <numbered fleet VM>.");
3215
+ }
3216
+ const { findings, declared, published } = await collectVmAppPortDrift(
3217
+ cfg,
3218
+ vmNumber,
3219
+ slugFor(vmNumber),
3220
+ );
3221
+ for (const f of findings) console.error(formatAppPortDriftWarning(f));
3222
+ console.log(formatPortDriftStatusLine(findings, { declared, published }));
3070
3223
  }
3071
3224
 
3072
3225
  /** Build the shell command that seeds a JSON patch into a host-side
@@ -3987,7 +4140,7 @@ function fleetInstancePrefix() {
3987
4140
  * (the VM is provisioned; ingress can be re-wired), Destroy likewise warns so a
3988
4141
  * teardown still removes the instance. `vmAccess` (up only) is the Add's intended
3989
4142
  * principal set, which the script's grant step uses in place of its sidecar read. */
3990
- function runVmIngress(cfg, action, vmNumber, domain, appPort, slug, vmAccess) {
4143
+ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug, vmAccess, { appPortUnresolved = false } = {}) {
3991
4144
  const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
3992
4145
  const inv = vmIngressInvocation({
3993
4146
  scriptPath,
@@ -4000,6 +4153,7 @@ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug, vmAccess) {
4000
4153
  instancePrefix: fleetInstancePrefix(),
4001
4154
  slug,
4002
4155
  vmAccess,
4156
+ appPortUnresolved,
4003
4157
  });
4004
4158
  return spawnSync(inv.command, inv.args, {
4005
4159
  stdio: "inherit",
@@ -5117,6 +5271,25 @@ async function healPreviewOriginsAcrossRoster(cfg, roster) {
5117
5271
  * resources already present and creates only the missing backend / host rule.
5118
5272
  * A no-op in legacy quick-tunnel mode (no fleet domain). Invoked on demand and
5119
5273
  * automatically at dashboard boot so a fleet that lost ingress self-heals. */
5274
+ /** Resolve one reconcile target's app port for `vm-ingress.sh up`, refusing to
5275
+ * guess. Resolves `{ appPort, appPortUnresolved: false }` on a real read, or
5276
+ * `{ appPort: null, appPortUnresolved: true }` — with a warning naming the VM —
5277
+ * when the probe failed, so the script leaves the preview NEG's endpoints alone
5278
+ * instead of wiring (and pruning to) a 5173 fallback over a working endpoint.
5279
+ * `deps.probeVmAppPort` is injectable so tests never reach for SSH. */
5280
+ async function resolveReconcileAppPort(perVmCfg, vmNumber, deps = {}) {
5281
+ const probe = deps.probeVmAppPort || probeVmAppPort;
5282
+ const { port, failure } = await probe(perVmCfg);
5283
+ if (!failure) return { appPort: port, appPortUnresolved: false };
5284
+ console.error(
5285
+ `[cloud] WARNING: VM-${vmNumber}: ${failure.what} — skipping the preview ` +
5286
+ `endpoint converge rather than wiring ${VITE_PORT} over a working endpoint. ` +
5287
+ `If the preview NEG targets the wrong port, Live Preview requests fail ` +
5288
+ `with "upstream connect error" until a later reconcile resolves it.`,
5289
+ );
5290
+ return { appPort: null, appPortUnresolved: true };
5291
+ }
5292
+
5120
5293
  async function cmdReconcileIngress(args, projectDir = rootDir) {
5121
5294
  ensureGcloud();
5122
5295
  parseFlags(args);
@@ -5189,17 +5362,23 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
5189
5362
  for (const t of targets) {
5190
5363
  const label = t.slug || `vm-${t.vmNumber}`;
5191
5364
  // Resolve the VM's real dev-server port so a re-created preview NEG targets
5192
- // the right port (not the 5173 bootstrap fallback). Best-effort — readVmAppPort
5193
- // itself falls back to VITE_PORT on any SSH/parse failure.
5365
+ // the right port. NOT readVmAppPort: its VITE_PORT fallback on an SSH/parse
5366
+ // failure is indistinguishable from a VM genuinely serving 5173, and `up`
5367
+ // prunes the preview NEG to the port it is given — a fallback would detach the
5368
+ // working endpoint and wire one nothing publishes. Unresolved → the script
5369
+ // leaves the preview endpoints alone; every other resource still converges.
5194
5370
  const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${t.vmNumber}` };
5195
- const appPort = await readVmAppPort(perVmCfg);
5196
- const res = runVmIngress(perVmCfg, "up", t.vmNumber, domain, appPort, t.slug);
5371
+ const { appPort, appPortUnresolved } = await resolveReconcileAppPort(perVmCfg, t.vmNumber);
5372
+ const res = runVmIngress(perVmCfg, "up", t.vmNumber, domain, appPort, t.slug, undefined, {
5373
+ appPortUnresolved,
5374
+ });
5197
5375
  console.error(
5198
5376
  ` VM-${t.vmNumber} ${label}: ${t.verdict} → ${res.status === 0 ? "re-wired" : "re-wire FAILED"}`,
5199
5377
  );
5200
5378
  }
5201
5379
 
5202
5380
  await healPreviewOriginsAcrossRoster(cfg, roster);
5381
+ await reportAppPortDriftAcrossRoster(cfg, roster);
5203
5382
 
5204
5383
  // Converge each rostered backend-service's timeout to 86400 (24h). A fully-wired
5205
5384
  // BES still at GCP's 30s default reports `wired` above and is skipped by the
@@ -5768,6 +5947,7 @@ const HANDLERS = {
5768
5947
  status: cmdStatus,
5769
5948
  "ingress-up": cmdIngressUp,
5770
5949
  "reconcile-ingress": cmdReconcileIngress,
5950
+ "app-port-drift": cmdAppPortDrift,
5771
5951
  "wire-add-tail": cmdWireAddTail,
5772
5952
  "reconcile-credentials": cmdReconcileCredentials,
5773
5953
  "reconcile-admin": cmdReconcileAdmin,
@@ -5862,6 +6042,8 @@ module.exports = {
5862
6042
  seedPreviewOrigin,
5863
6043
  healPreviewOriginAtReady,
5864
6044
  healPreviewOriginsAcrossRoster,
6045
+ reportAppPortDriftAcrossRoster,
6046
+ cmdAppPortDrift,
5865
6047
  buildGcloudArgs,
5866
6048
  sshScriptCommand,
5867
6049
  bootstrapRunnerScript,
@@ -5951,6 +6133,10 @@ module.exports = {
5951
6133
  classifyGitRemoteError,
5952
6134
  decideCredentialHeal,
5953
6135
  readVmAppPort,
6136
+ probeVmAppPort,
6137
+ resolveReconcileAppPort,
6138
+ parseVmDeclaredAppPort,
6139
+ parseVmAppPortsForDrift,
5954
6140
  buildVmAppPortReadCommand,
5955
6141
  resolveLaptopGitIdentity,
5956
6142
  gitBranchRefExists,
@@ -86,7 +86,11 @@ function regionFromZone(zone) {
86
86
  * ingress path granting a reused slot's prior tenant that the very next step then
87
87
  * revokes as stale (VMs 4/5/6, 2026-08-09). Omit it (or pass an empty list) to
88
88
  * leave the script's sidecar-read fallback in place — the standalone/reconcile
89
- * path, where no Add is deciding the intent. */
89
+ * path, where no Add is deciding the intent.
90
+ *
91
+ * `appPortUnresolved` (`up` only) becomes APP_PORT_UNRESOLVED=1: the caller tried
92
+ * to resolve the app port and could not, so the script leaves the preview NEG's
93
+ * endpoints untouched instead of wiring (and pruning to) a guessed port. */
90
94
  function vmIngressInvocation({
91
95
  scriptPath,
92
96
  vmNumber,
@@ -98,6 +102,7 @@ function vmIngressInvocation({
98
102
  instancePrefix,
99
103
  slug,
100
104
  vmAccess,
105
+ appPortUnresolved = false,
101
106
  } = {}) {
102
107
  if (!scriptPath) throw new Error('vmIngressInvocation requires scriptPath');
103
108
  if (action !== 'up' && action !== 'down') {
@@ -116,6 +121,7 @@ function vmIngressInvocation({
116
121
  if (slug != null && String(slug).trim() !== '') env.VM_SLUG = String(slug).trim();
117
122
  const access = (Array.isArray(vmAccess) ? vmAccess.join(',') : String(vmAccess == null ? '' : vmAccess)).trim();
118
123
  if (action === 'up' && access !== '') env.VM_ACCESS = access;
124
+ if (action === 'up' && appPortUnresolved) env.APP_PORT_UNRESOLVED = '1';
119
125
  return { command: 'bash', args: [scriptPath, String(n), action], env };
120
126
  }
121
127
 
@@ -141,7 +147,10 @@ function vmIngressStatusInvocation({
141
147
  throw new Error(`vm-ingress status requires a positive VM number, got: ${vmNumber}`);
142
148
  }
143
149
  if (!domain) throw new Error('vmIngressStatusInvocation requires a fleet domain');
144
- const env = { FLEET_DOMAIN: domain };
150
+ // Machine callers poll `status` for the LB verdict only; the port-drift field
151
+ // costs an SSH round-trip per call, and the roster reconcile runs that
152
+ // comparison itself. An operator running the script by hand still gets it.
153
+ const env = { FLEET_DOMAIN: domain, VM_INGRESS_SKIP_PORT_DRIFT: '1' };
145
154
  if (project) env.GCP_PROJECT = project;
146
155
  if (zone) env.ZONE = zone;
147
156
  if (slug != null && String(slug).trim() !== '') env.VM_SLUG = String(slug).trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codeyam-editor/codeyam-editor",
3
- "version": "0.1.13-staging.g1b6c798",
3
+ "version": "0.1.13",
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-staging.g1b6c798",
13
- "@codeyam-editor/codeyam-editor-darwin-x64": "0.1.13-staging.g1b6c798",
14
- "@codeyam-editor/codeyam-editor-linux-x64": "0.1.13-staging.g1b6c798",
15
- "@codeyam-editor/codeyam-editor-win32-x64": "0.1.13-staging.g1b6c798"
12
+ "@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.13",
13
+ "@codeyam-editor/codeyam-editor-darwin-x64": "0.1.13",
14
+ "@codeyam-editor/codeyam-editor-linux-x64": "0.1.13",
15
+ "@codeyam-editor/codeyam-editor-win32-x64": "0.1.13"
16
16
  },
17
17
  "keywords": [
18
18
  "codeyam",
@@ -1,2 +1,2 @@
1
- import{j as e}from"./markdown-C9tbsVHX.js";import{b as a}from"./react-nrLBr15I.js";import{u as k}from"./useEvents-DNb1olaj.js";import{k as E}from"./index-C9TVsqen.js";function P({connected:t}){return e.jsx("span",{className:`inline-block rounded-full px-2 py-0.5 text-xs font-medium text-[var(--text-primary)] ${t?"bg-[var(--accent-green)]":"bg-[var(--accent-red)]"}`,children:t?"connected":"reconnecting…"})}const re=Object.freeze(Object.defineProperty({__proto__:null,ConnectionBadge:P},Symbol.toStringTag,{value:"Module"}));function N(){const[t,r]=a.useState(null),[o,s]=a.useState(!0),{events:l}=k(),n=a.useCallback(async()=>{try{const c=await(await fetch("/api/inspector/status")).json();r(c)}catch{}finally{s(!1)}},[]);return a.useEffect(()=>{n()},[n]),a.useEffect(()=>{const i=l[l.length-1];i&&i.type==="scenario_switch"&&n()},[l,n]),{scenario:(t==null?void 0:t.scenario)??null,previewScenario:(t==null?void 0:t.previewScenario)??null,routes:(t==null?void 0:t.routes)??[],routeCount:(t==null?void 0:t.routeCount)??0,loading:o,refetch:n}}function v({title:t}){return e.jsx("h2",{style:{fontSize:14,fontWeight:600,color:"#f0f6fc",margin:"0 0 8px 0",borderBottom:"1px solid #30363d",paddingBottom:8},children:t})}function f({color:t,children:r}){return e.jsx("span",{style:{background:`${t}22`,color:t,border:`1px solid ${t}55`,borderRadius:4,padding:"2px 8px",fontSize:11,fontWeight:600,letterSpacing:"0.5px",whiteSpace:"nowrap"},children:r})}const $={GET:"#D7FF63",POST:"#238636",PUT:"#d29922",DELETE:"#da3633",PATCH:"#bc8cff"};function O({method:t}){const r=$[t.toUpperCase()]??"#8b949e";return e.jsx("span",{style:{color:r,fontWeight:600,fontSize:11,minWidth:48,display:"inline-block"},children:t.toUpperCase()})}function C({color:t,children:r}){return e.jsx("div",{style:{padding:"12px 16px",borderRadius:6,border:`1px solid ${t}44`,background:`${t}11`,color:t},children:r})}const u={padding:"8px 12px",textAlign:"center",color:"#8b949e",fontWeight:500,fontSize:11,textTransform:"uppercase",letterSpacing:"0.5px"},h={padding:"6px 12px"},S={background:"#161b22",border:"1px solid #30363d",borderRadius:6,color:"#c9d1d9",padding:"6px 12px",fontSize:13,fontFamily:"inherit"},R={...S,appearance:"none",paddingRight:24,backgroundImage:`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238b949e' d='M6 8L1 3h10z'/%3E%3C/svg%3E")`,backgroundRepeat:"no-repeat",backgroundPosition:"right 8px center"},oe=Object.freeze(Object.defineProperty({__proto__:null,Badge:f,MethodBadge:O,SectionHeader:v,StatusBox:C,inputStyle:S,selectStyle:R,tdStyle:h,thStyle:u},Symbol.toStringTag,{value:"Module"}));function z({routes:t}){return e.jsxs("table",{style:{width:"100%",borderCollapse:"collapse",fontSize:12},children:[e.jsx("thead",{children:e.jsxs("tr",{style:{borderBottom:"1px solid #30363d"},children:[e.jsx("th",{style:u,children:"Method"}),e.jsx("th",{style:{...u,textAlign:"left"},children:"Pattern"}),e.jsx("th",{style:u,children:"Status"})]})}),e.jsx("tbody",{children:t.map((r,o)=>e.jsxs("tr",{style:{borderBottom:"1px solid #21262d"},children:[e.jsx("td",{style:{...h,textAlign:"center"},children:e.jsx(O,{method:r.method})}),e.jsx("td",{style:h,children:r.pattern}),e.jsx("td",{style:{...h,textAlign:"center",color:"#8b949e"},children:r.status})]},o))})]})}const se=Object.freeze(Object.defineProperty({__proto__:null,InspectorRouteTable:z},Symbol.toStringTag,{value:"Module"}));function A(){const{scenario:t,previewScenario:r,routes:o,routeCount:s,loading:l}=N();return e.jsxs("section",{children:[e.jsx(v,{title:"Active Scenario"}),l?e.jsx(C,{color:"#8b949e",children:"Loading..."}):t?e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:12},children:[e.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8},children:[e.jsx(f,{color:"#238636",children:"ACTIVE"}),e.jsx("span",{style:{color:"#f0f6fc",fontWeight:500},children:t.name}),e.jsxs("span",{style:{color:"#8b949e"},children:["(",t.slug,")"]})]}),e.jsxs("div",{style:{color:"#8b949e",fontSize:12},children:[s," mock route",s!==1?"s":""," loaded"]}),o.length>0&&e.jsx(z,{routes:o})]}):r?e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:12},children:[e.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8},children:[e.jsx(f,{color:"#D7FF63",children:"PREVIEW"}),e.jsx("span",{style:{color:"#f0f6fc",fontWeight:500},children:r.name}),e.jsxs("span",{style:{color:"#8b949e"},children:["(",r.slug,")"]})]}),e.jsx("div",{style:{color:"#8b949e",fontSize:12},children:"Component preview — no HTTP mocks active"})]}):e.jsx(C,{color:"#d29922",children:"No scenario active — all requests pass through to upstream"})]})}const ne=Object.freeze(Object.defineProperty({__proto__:null,InspectorScenarioSection:A},Symbol.toStringTag,{value:"Module"}));function F({textUrlFilter:t,onTextUrlFilterChange:r,methodFilter:o,onMethodFilterChange:s,sourceFilter:l,onSourceFilterChange:n}){return e.jsxs("div",{style:{display:"flex",gap:8,marginBottom:12,alignItems:"center"},children:[e.jsx("input",{type:"text",placeholder:"Filter by URL...",value:t,onChange:i=>r(i.target.value),style:{...S,flex:1,minWidth:0}}),e.jsxs("select",{value:o,onChange:i=>s(i.target.value),style:R,children:[e.jsx("option",{value:"ALL",children:"All Methods"}),e.jsx("option",{value:"GET",children:"GET"}),e.jsx("option",{value:"POST",children:"POST"}),e.jsx("option",{value:"PUT",children:"PUT"}),e.jsx("option",{value:"DELETE",children:"DELETE"}),e.jsx("option",{value:"PATCH",children:"PATCH"})]}),e.jsxs("select",{value:l,onChange:i=>n(i.target.value),style:R,children:[e.jsx("option",{value:"ALL",children:"All Sources"}),e.jsx("option",{value:"MOCKED",children:"Mocked"}),e.jsx("option",{value:"PASSTHROUGH",children:"Passthrough"})]})]})}const ie=Object.freeze(Object.defineProperty({__proto__:null,InspectorTrafficFilterBar:F},Symbol.toStringTag,{value:"Module"})),G={color:"#8b949e",fontSize:11,fontWeight:600,textTransform:"uppercase",letterSpacing:"0.5px",marginBottom:4},M={background:"#161b22",border:"1px solid #30363d",borderRadius:4,padding:"8px 12px",margin:0,fontSize:11,color:"#c9d1d9",fontFamily:"monospace",maxHeight:200,overflow:"auto",whiteSpace:"pre-wrap",wordBreak:"break-all"};function _({label:t,children:r}){return e.jsxs("div",{style:{marginBottom:12},children:[e.jsx("div",{style:G,children:t}),r]})}function B({event:t}){let r=[];try{const n=new URL(t.url,"http://localhost");r=Array.from(n.searchParams.entries())}catch{}const o=t.requestHeaders&&Object.keys(t.requestHeaders).length>0,s=t.requestBody!=null,l=t.responseBody!=null;return e.jsxs("div",{style:{background:"#0d1117",padding:"12px 16px",borderTop:"1px solid #30363d"},children:[r.length>0&&e.jsx(_,{label:"Query Parameters",children:e.jsx("div",{style:{display:"grid",gridTemplateColumns:"auto 1fr",gap:"2px 12px",fontSize:12},children:r.map(([n,i],c)=>e.jsxs("div",{style:{display:"contents"},children:[e.jsx("span",{style:{color:"#D7FF63",fontFamily:"monospace"},children:n}),e.jsx("span",{style:{color:"#c9d1d9",fontFamily:"monospace"},children:i})]},c))})}),o&&e.jsx(_,{label:"Request Headers",children:e.jsx("pre",{style:M,children:Object.entries(t.requestHeaders).map(([n,i])=>`${n}: ${i}`).join(`
1
+ import{j as e}from"./markdown-C9tbsVHX.js";import{b as a}from"./react-nrLBr15I.js";import{u as k}from"./useEvents-DNb1olaj.js";import{k as E}from"./index-mgNPDFlH.js";function P({connected:t}){return e.jsx("span",{className:`inline-block rounded-full px-2 py-0.5 text-xs font-medium text-[var(--text-primary)] ${t?"bg-[var(--accent-green)]":"bg-[var(--accent-red)]"}`,children:t?"connected":"reconnecting…"})}const re=Object.freeze(Object.defineProperty({__proto__:null,ConnectionBadge:P},Symbol.toStringTag,{value:"Module"}));function N(){const[t,r]=a.useState(null),[o,s]=a.useState(!0),{events:l}=k(),n=a.useCallback(async()=>{try{const c=await(await fetch("/api/inspector/status")).json();r(c)}catch{}finally{s(!1)}},[]);return a.useEffect(()=>{n()},[n]),a.useEffect(()=>{const i=l[l.length-1];i&&i.type==="scenario_switch"&&n()},[l,n]),{scenario:(t==null?void 0:t.scenario)??null,previewScenario:(t==null?void 0:t.previewScenario)??null,routes:(t==null?void 0:t.routes)??[],routeCount:(t==null?void 0:t.routeCount)??0,loading:o,refetch:n}}function v({title:t}){return e.jsx("h2",{style:{fontSize:14,fontWeight:600,color:"#f0f6fc",margin:"0 0 8px 0",borderBottom:"1px solid #30363d",paddingBottom:8},children:t})}function f({color:t,children:r}){return e.jsx("span",{style:{background:`${t}22`,color:t,border:`1px solid ${t}55`,borderRadius:4,padding:"2px 8px",fontSize:11,fontWeight:600,letterSpacing:"0.5px",whiteSpace:"nowrap"},children:r})}const $={GET:"#D7FF63",POST:"#238636",PUT:"#d29922",DELETE:"#da3633",PATCH:"#bc8cff"};function O({method:t}){const r=$[t.toUpperCase()]??"#8b949e";return e.jsx("span",{style:{color:r,fontWeight:600,fontSize:11,minWidth:48,display:"inline-block"},children:t.toUpperCase()})}function C({color:t,children:r}){return e.jsx("div",{style:{padding:"12px 16px",borderRadius:6,border:`1px solid ${t}44`,background:`${t}11`,color:t},children:r})}const u={padding:"8px 12px",textAlign:"center",color:"#8b949e",fontWeight:500,fontSize:11,textTransform:"uppercase",letterSpacing:"0.5px"},h={padding:"6px 12px"},S={background:"#161b22",border:"1px solid #30363d",borderRadius:6,color:"#c9d1d9",padding:"6px 12px",fontSize:13,fontFamily:"inherit"},R={...S,appearance:"none",paddingRight:24,backgroundImage:`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238b949e' d='M6 8L1 3h10z'/%3E%3C/svg%3E")`,backgroundRepeat:"no-repeat",backgroundPosition:"right 8px center"},oe=Object.freeze(Object.defineProperty({__proto__:null,Badge:f,MethodBadge:O,SectionHeader:v,StatusBox:C,inputStyle:S,selectStyle:R,tdStyle:h,thStyle:u},Symbol.toStringTag,{value:"Module"}));function z({routes:t}){return e.jsxs("table",{style:{width:"100%",borderCollapse:"collapse",fontSize:12},children:[e.jsx("thead",{children:e.jsxs("tr",{style:{borderBottom:"1px solid #30363d"},children:[e.jsx("th",{style:u,children:"Method"}),e.jsx("th",{style:{...u,textAlign:"left"},children:"Pattern"}),e.jsx("th",{style:u,children:"Status"})]})}),e.jsx("tbody",{children:t.map((r,o)=>e.jsxs("tr",{style:{borderBottom:"1px solid #21262d"},children:[e.jsx("td",{style:{...h,textAlign:"center"},children:e.jsx(O,{method:r.method})}),e.jsx("td",{style:h,children:r.pattern}),e.jsx("td",{style:{...h,textAlign:"center",color:"#8b949e"},children:r.status})]},o))})]})}const se=Object.freeze(Object.defineProperty({__proto__:null,InspectorRouteTable:z},Symbol.toStringTag,{value:"Module"}));function A(){const{scenario:t,previewScenario:r,routes:o,routeCount:s,loading:l}=N();return e.jsxs("section",{children:[e.jsx(v,{title:"Active Scenario"}),l?e.jsx(C,{color:"#8b949e",children:"Loading..."}):t?e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:12},children:[e.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8},children:[e.jsx(f,{color:"#238636",children:"ACTIVE"}),e.jsx("span",{style:{color:"#f0f6fc",fontWeight:500},children:t.name}),e.jsxs("span",{style:{color:"#8b949e"},children:["(",t.slug,")"]})]}),e.jsxs("div",{style:{color:"#8b949e",fontSize:12},children:[s," mock route",s!==1?"s":""," loaded"]}),o.length>0&&e.jsx(z,{routes:o})]}):r?e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:12},children:[e.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8},children:[e.jsx(f,{color:"#D7FF63",children:"PREVIEW"}),e.jsx("span",{style:{color:"#f0f6fc",fontWeight:500},children:r.name}),e.jsxs("span",{style:{color:"#8b949e"},children:["(",r.slug,")"]})]}),e.jsx("div",{style:{color:"#8b949e",fontSize:12},children:"Component preview — no HTTP mocks active"})]}):e.jsx(C,{color:"#d29922",children:"No scenario active — all requests pass through to upstream"})]})}const ne=Object.freeze(Object.defineProperty({__proto__:null,InspectorScenarioSection:A},Symbol.toStringTag,{value:"Module"}));function F({textUrlFilter:t,onTextUrlFilterChange:r,methodFilter:o,onMethodFilterChange:s,sourceFilter:l,onSourceFilterChange:n}){return e.jsxs("div",{style:{display:"flex",gap:8,marginBottom:12,alignItems:"center"},children:[e.jsx("input",{type:"text",placeholder:"Filter by URL...",value:t,onChange:i=>r(i.target.value),style:{...S,flex:1,minWidth:0}}),e.jsxs("select",{value:o,onChange:i=>s(i.target.value),style:R,children:[e.jsx("option",{value:"ALL",children:"All Methods"}),e.jsx("option",{value:"GET",children:"GET"}),e.jsx("option",{value:"POST",children:"POST"}),e.jsx("option",{value:"PUT",children:"PUT"}),e.jsx("option",{value:"DELETE",children:"DELETE"}),e.jsx("option",{value:"PATCH",children:"PATCH"})]}),e.jsxs("select",{value:l,onChange:i=>n(i.target.value),style:R,children:[e.jsx("option",{value:"ALL",children:"All Sources"}),e.jsx("option",{value:"MOCKED",children:"Mocked"}),e.jsx("option",{value:"PASSTHROUGH",children:"Passthrough"})]})]})}const ie=Object.freeze(Object.defineProperty({__proto__:null,InspectorTrafficFilterBar:F},Symbol.toStringTag,{value:"Module"})),G={color:"#8b949e",fontSize:11,fontWeight:600,textTransform:"uppercase",letterSpacing:"0.5px",marginBottom:4},M={background:"#161b22",border:"1px solid #30363d",borderRadius:4,padding:"8px 12px",margin:0,fontSize:11,color:"#c9d1d9",fontFamily:"monospace",maxHeight:200,overflow:"auto",whiteSpace:"pre-wrap",wordBreak:"break-all"};function _({label:t,children:r}){return e.jsxs("div",{style:{marginBottom:12},children:[e.jsx("div",{style:G,children:t}),r]})}function B({event:t}){let r=[];try{const n=new URL(t.url,"http://localhost");r=Array.from(n.searchParams.entries())}catch{}const o=t.requestHeaders&&Object.keys(t.requestHeaders).length>0,s=t.requestBody!=null,l=t.responseBody!=null;return e.jsxs("div",{style:{background:"#0d1117",padding:"12px 16px",borderTop:"1px solid #30363d"},children:[r.length>0&&e.jsx(_,{label:"Query Parameters",children:e.jsx("div",{style:{display:"grid",gridTemplateColumns:"auto 1fr",gap:"2px 12px",fontSize:12},children:r.map(([n,i],c)=>e.jsxs("div",{style:{display:"contents"},children:[e.jsx("span",{style:{color:"#D7FF63",fontFamily:"monospace"},children:n}),e.jsx("span",{style:{color:"#c9d1d9",fontFamily:"monospace"},children:i})]},c))})}),o&&e.jsx(_,{label:"Request Headers",children:e.jsx("pre",{style:M,children:Object.entries(t.requestHeaders).map(([n,i])=>`${n}: ${i}`).join(`
2
2
  `)})}),s&&e.jsx(_,{label:"Request Body",children:e.jsx("pre",{style:M,children:typeof t.requestBody=="string"?t.requestBody:JSON.stringify(t.requestBody,null,2)})}),l&&e.jsx(_,{label:"Response Body",children:e.jsx("pre",{style:M,children:typeof t.responseBody=="string"?t.responseBody:JSON.stringify(t.responseBody,null,2)})}),r.length===0&&!o&&!s&&!l&&e.jsx("div",{style:{color:"#8b949e",fontSize:12},children:"No additional details available for this request"})]})}const le=Object.freeze(Object.defineProperty({__proto__:null,DetailSection:_,InspectorTrafficDetail:B},Symbol.toStringTag,{value:"Module"}));function L({event:t}){const[r,o]=a.useState(!1),s=new Date(t.timestampMs).toLocaleTimeString(),l=t.mocked;return e.jsxs(e.Fragment,{children:[e.jsxs("tr",{onClick:()=>o(!r),style:{borderBottom:"1px solid #21262d",background:l?"transparent":"rgba(210, 153, 34, 0.05)",cursor:"pointer"},children:[e.jsxs("td",{style:{...h,color:"#8b949e",textAlign:"center"},children:[e.jsx("span",{style:{display:"inline-block",width:12,marginRight:4,fontSize:10,color:"#8b949e",transition:"transform 0.15s",transform:r?"rotate(90deg)":"rotate(0deg)"},children:"▶"}),s]}),e.jsx("td",{style:{...h,textAlign:"center"},children:e.jsx(O,{method:t.method})}),e.jsx("td",{style:{...h,maxWidth:400,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:t.url}),e.jsx("td",{style:{...h,textAlign:"center",color:"#8b949e"},children:t.status??"---"}),e.jsxs("td",{style:{...h,textAlign:"center",color:"#8b949e"},children:[t.durationMs,"ms"]}),e.jsx("td",{style:{...h,textAlign:"center"},children:l?t.mockSource==="default"?e.jsx(f,{color:"#1f6feb",children:"MOCKED (default)"}):e.jsx(f,{color:"#238636",children:"MOCKED (scenario)"}):e.jsx(f,{color:"#d29922",children:"PASSTHROUGH"})})]}),r&&e.jsx("tr",{children:e.jsx("td",{colSpan:6,style:{padding:0},children:e.jsx(B,{event:t})})})]})}const ae=Object.freeze(Object.defineProperty({__proto__:null,InspectorTrafficRow:L},Symbol.toStringTag,{value:"Module"}));function D({events:t}){return e.jsx("div",{style:{maxHeight:300,overflowY:"auto",border:"1px solid #30363d",borderRadius:6},children:e.jsxs("table",{style:{width:"100%",borderCollapse:"collapse",fontSize:12},children:[e.jsx("thead",{children:e.jsxs("tr",{style:{borderBottom:"1px solid #30363d",position:"sticky",top:0,background:"#0d1117"},children:[e.jsx("th",{style:u,children:"Time"}),e.jsx("th",{style:u,children:"Method"}),e.jsx("th",{style:{...u,textAlign:"left"},children:"URL"}),e.jsx("th",{style:u,children:"Status"}),e.jsx("th",{style:u,children:"Duration"}),e.jsx("th",{style:u,children:"Source"})]})}),e.jsx("tbody",{children:t.map(r=>e.jsx(L,{event:r},r.id))})]})})}const ce=Object.freeze(Object.defineProperty({__proto__:null,InspectorTrafficTable:D},Symbol.toStringTag,{value:"Module"}));function K(t){if(t===void 0)return"checked";if(t==="checked")return"excluded"}function I({chips:t,chipStates:r,onChipStatesChange:o}){if(t.length===0)return null;const s=n=>{const i=new Map(r),c=K(i.get(n));c===void 0?i.delete(n):i.set(n,c),o(i)},l=()=>{o(new Map)};return e.jsxs("div",{style:{display:"flex",flexWrap:"wrap",gap:6,marginBottom:12},children:[r.size>0&&e.jsx("button",{onClick:l,style:{background:"#da363322",color:"#da3633",border:"1px solid #da363355",borderRadius:4,padding:"2px 8px",fontSize:11,fontWeight:600,cursor:"pointer",fontFamily:"inherit"},children:"Clear all"}),t.map(({path:n,count:i})=>{const c=r.get(n),d=c==="checked",g=c==="excluded";let m="#30363d",b="#8b949e",T="#30363d",y="";return d?(m="#D7FF6322",b="#D7FF63",T="#D7FF6355",y="✓ "):g&&(m="#da363322",b="#da3633",T="#da363355",y="✗ "),e.jsxs("button",{onClick:()=>s(n),style:{background:m,color:b,border:`1px solid ${T}`,borderRadius:4,padding:"2px 8px",fontSize:11,fontFamily:"monospace",cursor:"pointer",whiteSpace:"nowrap"},children:[y,n,e.jsxs("span",{style:{marginLeft:4,opacity:.6,fontFamily:"inherit"},children:["(",i,")"]})]},n)})]})}const de=Object.freeze(Object.defineProperty({__proto__:null,InspectorTrafficUrlChips:I},Symbol.toStringTag,{value:"Module"}));function H(){const{events:t}=k(),[r,o]=a.useState(""),[s,l]=a.useState(()=>new Map),[n,i]=a.useState("ALL"),[c,d]=a.useState("ALL"),g=t.filter(p=>p.type==="http_request"),m=a.useMemo(()=>{const p=new Map;for(const x of g){const j=E(x.url);p.set(j,(p.get(j)||0)+1)}return Array.from(p.entries()).sort((x,j)=>j[1]-x[1]).slice(0,10).map(([x,j])=>({path:x,count:j}))},[g]),b=a.useMemo(()=>Array.from(s.values()).some(p=>p==="checked"),[s]),y=g.filter(p=>{if(r&&!p.url.toLowerCase().includes(r.toLowerCase()))return!1;const x=E(p.url);return!(s.get(x)==="excluded"||b&&s.get(x)!=="checked"||n!=="ALL"&&p.method.toUpperCase()!==n||c==="MOCKED"&&!p.mocked||c==="PASSTHROUGH"&&p.mocked)}).slice(-50).reverse();return e.jsxs("section",{children:[e.jsx(v,{title:"Live Traffic"}),e.jsx(F,{textUrlFilter:r,onTextUrlFilterChange:o,methodFilter:n,onMethodFilterChange:i,sourceFilter:c,onSourceFilterChange:p=>d(p)}),e.jsx(I,{chips:m,chipStates:s,onChipStatesChange:l}),y.length===0?e.jsx(C,{color:"#8b949e",children:g.length>0?"No matching traffic — try adjusting the filters":"No HTTP traffic yet — requests will appear here in real-time"}):e.jsx(D,{events:y})]})}const pe=Object.freeze(Object.defineProperty({__proto__:null,InspectorTrafficSection:H},Symbol.toStringTag,{value:"Module"}));function J(){const[t,r]=a.useState(null),[o,s]=a.useState(!1);return{explain:a.useCallback(async(n,i)=>{s(!0);try{const d=await(await fetch("/api/inspector/explain",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({method:n,url:i})})).json();r(d)}catch{}finally{s(!1)}},[]),result:t,loading:o}}function U({result:t}){return e.jsxs("div",{style:{border:"1px solid #30363d",borderRadius:6,padding:12},children:[e.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,marginBottom:12},children:[e.jsxs("span",{style:{color:"#f0f6fc",fontWeight:500},children:[t.method," ",t.url]}),t.wouldMatch?e.jsx(f,{color:"#238636",children:"WOULD MATCH"}):e.jsx(f,{color:"#da3633",children:"NO MATCH"})]}),t.attempts.length===0?e.jsx("div",{style:{color:"#8b949e"},children:"No routes loaded to match against"}):e.jsx("div",{style:{display:"flex",flexDirection:"column",gap:6},children:t.attempts.map((r,o)=>e.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,padding:"4px 8px",borderRadius:4,background:r.failure?"transparent":"rgba(35, 134, 54, 0.1)"},children:[e.jsx("span",{style:{color:r.failure?"#da3633":"#238636",fontWeight:600,width:16},children:r.failure?"x":"v"}),e.jsx(O,{method:r.method}),e.jsx("span",{children:r.pattern}),r.failure&&e.jsx("span",{style:{color:"#8b949e",fontSize:11},children:r.failure.reason==="methodMismatch"?`method: expected ${r.failure.expected}, got ${r.failure.actual}`:`path "${r.failure.path}" does not match "${r.failure.pattern}"`})]},o))})]})}const ue=Object.freeze(Object.defineProperty({__proto__:null,InspectorExplainerResult:U},Symbol.toStringTag,{value:"Module"}));function W(){const{explain:t,result:r,loading:o}=J(),[s,l]=a.useState("GET"),[n,i]=a.useState(""),c=d=>{d.preventDefault(),n.trim()&&t(s,n.trim())};return e.jsxs("section",{children:[e.jsx(v,{title:"Match Explainer"}),e.jsxs("form",{onSubmit:c,style:{display:"flex",gap:8,marginBottom:12},children:[e.jsx("select",{value:s,onChange:d=>l(d.target.value),style:{...S,width:100},children:["GET","POST","PUT","DELETE","PATCH"].map(d=>e.jsx("option",{value:d,children:d},d))}),e.jsx("input",{type:"text",value:n,onChange:d=>i(d.target.value),placeholder:"/api/users",style:{...S,flex:1}}),e.jsx("button",{type:"submit",disabled:o||!n.trim(),style:{background:"#238636",color:"#fff",border:"none",borderRadius:6,padding:"6px 16px",cursor:"pointer",fontSize:12,fontWeight:500,opacity:o||!n.trim()?.5:1},children:o?"Checking...":"Explain"})]}),r&&e.jsx(U,{result:r})]})}const he=Object.freeze(Object.defineProperty({__proto__:null,InspectorExplainerSection:W},Symbol.toStringTag,{value:"Module"})),w={"/api/health":"Health check — confirms the proxy and mock engine are running","/api/events":"SSE stream of real-time events (traffic, errors) powering this Inspector","/api/config":"Returns current CodeYam configuration for the project","/api/editor-dev-server":"Polls/controls the dev server status (start, stop, restart)","/api/inspector/status":"Returns active scenario and loaded mock routes","/api/inspector/explain":"Tests which mock route would match a given method + URL","/api/scenarios":"Lists all defined scenarios with metadata","/api/tests":"Returns the AI-maintained test registry joined with per-test-evidence, grouped by file","/api/test-results":"Returns latest test execution results","/api/screenshots/*":"Serves captured scenario screenshots","/api/session-info":"Returns current editor session metadata","/api/data-structure":"Returns the app's data structure definition","/api/dependency-graph":"Returns the project's component dependency graph","/api/app-route-entities":"Lists app routes and their associated entities","/api/glossary":"Returns the project's component glossary","/api/step":"Returns current editor workflow step","/api/editor-commands":"Lists available editor commands"};function V(t){if(w[t])return w[t];for(const[r,o]of Object.entries(w))if(r.endsWith("/*")){const s=r.slice(0,-1);if(t.startsWith(s))return o}}function Y(t){const r=new Set([...t,...Object.keys(w)]),o=[];for(const s of Array.from(r).sort()){if(s.endsWith("/*"))continue;const l=V(s);o.push({path:s,description:l??"Application endpoint"})}return o}function q(){const{events:t}=k(),r=a.useMemo(()=>{const o=[];for(const s of t)s.type==="http_request"&&o.push(E(s.url));return Y(o)},[t]);return r.length===0?null:e.jsxs("section",{children:[e.jsx(v,{title:"URL Glossary"}),e.jsx("div",{style:{border:"1px solid #30363d",borderRadius:6,overflow:"hidden"},children:e.jsxs("table",{style:{width:"100%",borderCollapse:"collapse",fontSize:12},children:[e.jsx("thead",{children:e.jsxs("tr",{style:{borderBottom:"1px solid #30363d",background:"#161b22"},children:[e.jsx("th",{style:{padding:"6px 12px",textAlign:"left",color:"#8b949e",fontWeight:500,fontSize:11,textTransform:"uppercase",letterSpacing:"0.5px",width:"30%"},children:"Path"}),e.jsx("th",{style:{padding:"6px 12px",textAlign:"left",color:"#8b949e",fontWeight:500,fontSize:11,textTransform:"uppercase",letterSpacing:"0.5px"},children:"Description"})]})}),e.jsx("tbody",{children:r.map(({path:o,description:s})=>e.jsxs("tr",{style:{borderBottom:"1px solid #21262d"},children:[e.jsx("td",{style:{padding:"4px 12px",fontFamily:"monospace",color:"#D7FF63",fontSize:11},children:o}),e.jsx("td",{style:{padding:"4px 12px",color:"#8b949e"},children:s})]},o))})]})})]})}const xe=Object.freeze(Object.defineProperty({__proto__:null,InspectorUrlGlossary:q},Symbol.toStringTag,{value:"Module"}));function Q(){const{connected:t}=k();return e.jsxs("div",{style:{background:"#0d1117",color:"#c9d1d9",minHeight:"100vh",fontFamily:"'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace",fontSize:13,padding:24,display:"flex",flexDirection:"column",gap:24},children:[e.jsxs("header",{style:{display:"flex",alignItems:"center",gap:12},children:[e.jsx("h1",{style:{fontSize:18,fontWeight:600,color:"#f0f6fc",margin:0},children:"Container Inspector"}),e.jsx("span",{style:{color:"#8b949e",fontSize:12},children:"Read-only view of proxy and mock engine state"}),e.jsx(P,{connected:t})]}),e.jsx(A,{}),e.jsx(H,{}),e.jsx(W,{}),e.jsx(q,{})]})}const fe=Object.freeze(Object.defineProperty({__proto__:null,ContainerInspector:Q},Symbol.toStringTag,{value:"Module"}));export{f as B,Q as C,_ as D,L as I,O as M,v as S,xe as _,C as a,q as b,I as c,D as d,F as e,B as f,U as g,W as h,H as i,z as j,A as k,P as l,de as m,ce as n,pe as o,ae as p,ie as q,le as r,ne as s,u as t,se as u,oe as v,he as w,ue as x,fe as y,re as z};