@codeyam-editor/codeyam-editor
Advanced tools
| 'use strict'; | ||
| // Pure branch-resolution precedence for the fleet dashboard (improve39 A). | ||
| // | ||
| // The dashboard's "current fleet branch" used to go stale: server.js read a | ||
| // baked process.env.BRANCH (set once by the launchd plist at install time) and | ||
| // a hardcoded 'editor-improvements-30' literal, so a `git checkout` to a new | ||
| // fleet branch on the laptop was invisible until the operator re-ran | ||
| // install-launchd-agents.sh (which then risked taking the dashboard down — the | ||
| // (B) half of improve39). This module makes the resolution RULE a pure, | ||
| // unit-tested function (npm/fleet-branch.test.js); server.js owns the I/O — the | ||
| // execSync git read and the short-TTL memo on the ~3s /data poll path. | ||
| // | ||
| // Precedence: explicit env override -> live git HEAD -> fallback sentinel. | ||
| // An operator can still pin a branch with BRANCH=... in the plist/env, but with | ||
| // no pin the dashboard tracks the laptop checkout's live HEAD, so a branch bump | ||
| // is just a `git checkout` — no re-install. | ||
| // Returned when neither an explicit pin nor a live git read yields a branch — a | ||
| // clearly-not-a-branch marker, chosen over a stale literal like | ||
| // 'editor-improvements-30' that would silently point Reset/Update at a dead | ||
| // branch. Callers can surface it as "unknown" instead of acting on it. | ||
| const UNKNOWN_BRANCH = 'unknown-branch'; | ||
| /** Normalize a candidate branch value to a non-empty trimmed string, or null. | ||
| * Anything non-string, empty, or whitespace-only counts as "absent" so a | ||
| * failed git read (null) or an unset env var ('') falls through to the next | ||
| * source. */ | ||
| function cleanBranch(v) { | ||
| if (typeof v !== 'string') return null; | ||
| const t = v.trim(); | ||
| return t === '' ? null : t; | ||
| } | ||
| /** Resolve the fleet's active editor branch by precedence: | ||
| * | ||
| * explicit env override -> live git HEAD -> fallback. | ||
| * | ||
| * `fallback` defaults to the UNKNOWN_BRANCH sentinel, so a total failure | ||
| * (no pin, no git) yields a marker rather than a stale branch literal. | ||
| * Pure; no I/O. */ | ||
| function resolveFleetBranch({ envBranch, gitBranch, fallback } = {}) { | ||
| const pin = cleanBranch(envBranch); | ||
| if (pin) return pin; | ||
| const live = cleanBranch(gitBranch); | ||
| if (live) return live; | ||
| return cleanBranch(fallback) || UNKNOWN_BRANCH; | ||
| } | ||
| module.exports = { UNKNOWN_BRANCH, cleanBranch, resolveFleetBranch }; |
| 'use strict'; | ||
| // Pure alarm-only classification for the Fleet Dashboard's broker-death line | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). Given the rolling | ||
| // `brokerHistory` forensics and the live tri-state `brokerStatus`, decide | ||
| // whether the newest death is a LIVE problem worth alarming about. No I/O — | ||
| // server.js does the polling. | ||
| // | ||
| // Contract (improve40): the death line alarms ONLY when the broker is provably | ||
| // down right now. For every other status it returns null and the card omits the | ||
| // row entirely. | ||
| // | ||
| // Why alarm-only: the live broker-status dot (renderBrokerStatus) already shows | ||
| // current health. The death line's only job is to EXPLAIN a broker that is down | ||
| // now (with the recorded reason / uptime / count). A death record for a broker | ||
| // that is back up is superseded history — the old muted "↩ broker recovered" | ||
| // footnote was noise. And a death we can't confirm (editor unreachable → | ||
| // 'unknown') must not over-claim: "absence of evidence is never down" is the | ||
| // established principle in npm/fleet-broker-status.js. The earlier | ||
| // recovered/active/unconfirmed tri-state collapses to a single down-only alarm. | ||
| // | ||
| // `brokerStatus` here is the DEBOUNCED tri-state from | ||
| // npm/fleet-broker-debounce.js, so a single transient probe timeout can never | ||
| // reach this as 'down'. | ||
| function classifyBrokerDeath(brokerHistory, brokerStatus) { | ||
| // Alarm only when the broker is confirmed down now. Every other status (up, | ||
| // unreachable, missing) → no death line. | ||
| if (brokerStatus !== 'down') return null; | ||
| // Common "broker never died" case → card renders nothing, unchanged. | ||
| if (!Array.isArray(brokerHistory) || brokerHistory.length === 0) return null; | ||
| const newest = brokerHistory[brokerHistory.length - 1]; | ||
| if (!newest) return null; | ||
| // Mirror renderBrokerDeath's existing reason fallback chain. | ||
| const reason = newest.reason || newest.signalName || ('exit ' + newest.exitCode); | ||
| return { | ||
| reason, | ||
| // Passed through verbatim (may be null/absent — the renderer guards both). | ||
| atUnixSecs: newest.atUnixSecs, | ||
| uptimeSeconds: newest.uptimeSeconds, | ||
| count: brokerHistory.length, | ||
| }; | ||
| } | ||
| module.exports = { classifyBrokerDeath }; |
| 'use strict'; | ||
| // Pure consecutive-failure debounce for the Fleet Dashboard's broker tri-state | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). Given the raw | ||
| // single-poll status from npm/fleet-broker-status.js::deriveBrokerStatus and the | ||
| // per-VM debounce state from the previous poll, decide the status the card | ||
| // actually renders. No I/O — server.js does the polling and threads the state. | ||
| // | ||
| // The bug this fixes (observed live 2026-06-02, VM3): the dashboard polls each | ||
| // VM's broker every 3s, and the editor probes the broker with a single 200ms | ||
| // socket attempt. One transient timeout (IAP / SSH-tunnel congestion — the same | ||
| // class of hiccup behind the known false "Editor stopped") instantly flipped a | ||
| // healthy VM's card to 'down', which raised both the red broker-down dot and the | ||
| // 💀 death alarm, even though the broker was fine (the build tab opened and chat | ||
| // started immediately). The single probe is left as-is on the editor side; the | ||
| // right seam for absorbing a one-poll blip is here at the aggregation layer. | ||
| // | ||
| // Asymmetric by design: ONLY the flip to 'down' is debounced (it requires | ||
| // `threshold` consecutive 'down' polls). 'idle' / 'agent-live' / 'unknown' take | ||
| // effect immediately — we never want to DELAY clearing a false alarm, only | ||
| // delay raising one. And an unreachable editor ('unknown') breaks a pending | ||
| // down-streak rather than escalating it, mirroring fleet-broker-status.js's | ||
| // "absence of evidence is never down" rule. | ||
| // | ||
| // State shape carried across polls: { status, downStreak, lastConfirmed }. | ||
| // status — the debounced value the card renders this poll. | ||
| // downStreak — how many consecutive raw 'down' polls we've seen. | ||
| // lastConfirmed — the last raw 'idle'/'agent-live' we proved; held during a | ||
| // pending (sub-threshold) down-streak so the card stays calm. | ||
| // ~9s at the dashboard's 3s poll cadence: comfortably absorbs a transient probe | ||
| // timeout and the brief down-window of a routine restart-server (broker down for | ||
| // <3s between SIGTERM and respawn), while still surfacing a real outage quickly. | ||
| // Named so retuning is a one-line edit. | ||
| const DOWN_DEBOUNCE_THRESHOLD = 3; | ||
| function debounceBrokerStatus(prev, rawStatus, opts) { | ||
| const threshold = (opts && opts.threshold) || DOWN_DEBOUNCE_THRESHOLD; | ||
| const lastConfirmed = prev ? prev.lastConfirmed : undefined; | ||
| // Confirmed up: the daemon answered now. Render immediately, reset the streak, | ||
| // and remember this as the last-confirmed-up value. | ||
| if (rawStatus === 'idle' || rawStatus === 'agent-live') { | ||
| return { status: rawStatus, downStreak: 0, lastConfirmed: rawStatus }; | ||
| } | ||
| // Editor unreachable: we proved nothing. Never down — reset any pending | ||
| // down-streak and render 'unknown' (the card shows nothing for it). | ||
| if (rawStatus !== 'down') { | ||
| return { status: 'unknown', downStreak: 0, lastConfirmed }; | ||
| } | ||
| // Raw 'down': only a real outage if it persists. Count the streak. | ||
| const downStreak = (prev ? prev.downStreak : 0) + 1; | ||
| if (downStreak >= threshold) { | ||
| // Confirmed down — surface the alarm. | ||
| return { status: 'down', downStreak, lastConfirmed }; | ||
| } | ||
| // Pending (sub-threshold) down: hold the last confirmed up status if we have | ||
| // one, else 'unknown'. The card stays calm during the blip. | ||
| return { status: lastConfirmed || 'unknown', downStreak, lastConfirmed }; | ||
| } | ||
| module.exports = { debounceBrokerStatus, DOWN_DEBOUNCE_THRESHOLD }; |
| 'use strict'; | ||
| // Pure tri-state derivation for the Fleet Dashboard's per-VM broker indicator | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). Given the raw | ||
| // /api/session-info poll merge `v`, collapse it into the one label the card | ||
| // renders. No I/O — server.js does the polling. | ||
| // | ||
| // The bug this fixes (observed live 2026-06-01): the old indicator was | ||
| // `v.reachable ? !!v.agentBrokerLive : null`, where `agentBrokerLive` means | ||
| // "a build-mode agent session is live in the broker" — NOT "broker daemon up". | ||
| // So a perfectly IDLE VM (healthy broker daemon, no parked build agent) reported | ||
| // `agentBrokerLive=false`, indistinguishable from a VM whose broker was actually | ||
| // DOWN. An operator killed a working broker chasing that phantom. The editor now | ||
| // also reports `brokerDaemonReachable` (the daemon answered its socket) and a | ||
| // derived `brokerStatus`; this module layers the dashboard's own reachability on | ||
| // top so the four states stay distinct: | ||
| // | ||
| // 'unknown' — editor unreachable (can't probe the broker at all), OR an | ||
| // older VM whose editor predates `brokerDaemonReachable`. Never | ||
| // rendered as a problem: absence of evidence is not "down". | ||
| // 'down' — daemon unreachable → the real broker problem, the state that | ||
| // had NO signal before. A VM here cannot start an agent session. | ||
| // 'idle' — daemon up, no build agent → a normal idle VM, not an error. | ||
| // 'agent-live' — a live build-mode agent session in the broker's build slot. | ||
| const BROKER_STATUSES = ['unknown', 'down', 'idle', 'agent-live']; | ||
| function deriveBrokerStatus(v) { | ||
| // Editor unreachable: we polled nothing, so the broker state is unknowable. | ||
| // Reporting 'down' here is exactly the conflation that misled operators — | ||
| // an unreachable editor is not a dead broker. | ||
| if (!v || !v.reachable) return 'unknown'; | ||
| // Prefer the editor's own tri-state when it reports one (newer VMs). | ||
| if (v.brokerStatus === 'down' || v.brokerStatus === 'idle' || v.brokerStatus === 'agent-live') { | ||
| return v.brokerStatus; | ||
| } | ||
| // Fallback derivation from the raw bits, for a VM reporting the bools but not | ||
| // the pre-derived `brokerStatus`. `agentBrokerLive` implies the daemon is up. | ||
| if (v.agentBrokerLive) return 'agent-live'; | ||
| if (v.brokerDaemonReachable === true) return 'idle'; | ||
| if (v.brokerDaemonReachable === false) return 'down'; | ||
| // Older VM: reachable editor, but no broker-reachability field at all. Treat | ||
| // as 'unknown' (graceful fallback) — never falsely 'down'. | ||
| return 'unknown'; | ||
| } | ||
| module.exports = { deriveBrokerStatus, BROKER_STATUSES }; |
| 'use strict'; | ||
| // Pure predicate module for the Fleet Dashboard's per-button "will clicking | ||
| // this change the VM, or is it already in that state?" indicators | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). Given an | ||
| // already-gathered FACTS object — no I/O, no git/docker exec; server.js does | ||
| // the impure fact-gathering — computeButtonEffects returns a per-button verdict | ||
| // map. Mirrors the pure-module-plus-colocated-vitest-test shape of | ||
| // npm/fleet-reset.js so the whole decision table is unit-testable without | ||
| // spawning a process. | ||
| // | ||
| // Each verdict is { effect, reason }: | ||
| // - 'changes' — clicking does something; `reason` says what. | ||
| // - 'noop' — already in that state; clicking changes nothing. | ||
| // - 'recommended' — soft signal (run-setup only): a reset/update landed after | ||
| // the last successful setup. NEVER a confident green no-op. | ||
| // - 'unknown' — not yet checked, VM unreachable, or a needed fact is | ||
| // missing. We never CLAIM a safe no-op we couldn't verify — | ||
| // silent failure here would be worse than no indicator. | ||
| // | ||
| // The predicate for each button is derived from what that button actually | ||
| // touches (its tooltip in public/index.html is the source of truth): the sync | ||
| // buttons key off a git commit comparison, the reset buttons off | ||
| // working-tree/session/`.codeyam` state, and rebuild off a build-commit | ||
| // comparison. | ||
| // Short sha for compact reason strings. Tolerates null / short input. | ||
| function short(sha) { | ||
| return sha ? String(sha).slice(0, 7) : '?'; | ||
| } | ||
| // "origin/<branch>" — or a bare "origin" when the branch is unknown. | ||
| function originRef(branch) { | ||
| return branch ? `origin/${branch}` : 'origin'; | ||
| } | ||
| // "N commit(s) behind origin/<branch>" with correct singular/plural. | ||
| function behindReason(behind, branch) { | ||
| return `${behind} commit${behind === 1 ? '' : 's'} behind ${originRef(branch)}`; | ||
| } | ||
| // Compose a short reason listing which signals make a reset non-trivial, so the | ||
| // operator sees WHAT a Reset would discard rather than a bare "changes". The | ||
| // `agent` (a live build agent in the broker) leads the list — it's the most | ||
| // consequential thing a reset's `pty-broker stop` would silently discard. | ||
| function resetReason({ agent, session, dirty, behind, codeyam }) { | ||
| const parts = []; | ||
| if (agent) parts.push('running build agent'); | ||
| if (dirty) parts.push('uncommitted changes'); | ||
| if (behind) parts.push('source behind origin'); | ||
| if (session) parts.push('active session'); | ||
| if (codeyam) parts.push('.codeyam state'); | ||
| return parts.length ? `will discard: ${parts.join(', ')}` : 'will reset state'; | ||
| } | ||
| // The action ids governed by an effect indicator — the same ids server.js / | ||
| // index.html use for the buttons. Reconfigure / Env / Destroy are deliberately | ||
| // absent (one-shots, not converge-to-a-state actions, so a no-op predicate is | ||
| // meaningless for them). | ||
| const ACTION_IDS = [ | ||
| 'update-client-source', | ||
| 'update-editor-source', | ||
| 'reset-editor-source', | ||
| 'reset-client', | ||
| 'rebuild-binary', | ||
| 'update-reset-everything', | ||
| 'run-setup', | ||
| ]; | ||
| // facts (a plain object gathered by server.js — no I/O): | ||
| // { | ||
| // reachable: boolean, | ||
| // hasSession: boolean, | ||
| // client: { headSha, originSha, behind, dirty } | null, // /workspace | ||
| // editor: { headSha, originSha, behind, dirty, buildCommit } | null, | ||
| // codeyamDirty: boolean, | ||
| // setupRanSince: boolean, // a setup completed after the last reset/update | ||
| // clientBranch: string | null, // for reason text only | ||
| // editorBranch: string | null, // for reason text only | ||
| // } | ||
| function computeButtonEffects(facts) { | ||
| const f = facts || {}; | ||
| // Unreachable VM (or no facts at all): every governed button is unknown. We | ||
| // could not reach the editor to gather facts, so never render a green no-op. | ||
| if (!f.reachable) { | ||
| const out = {}; | ||
| for (const id of ACTION_IDS) out[id] = { effect: 'unknown', reason: 'VM unreachable' }; | ||
| return out; | ||
| } | ||
| const client = f.client || null; | ||
| const editor = f.editor || null; | ||
| const hasSession = !!f.hasSession; | ||
| const codeyamDirty = !!f.codeyamDirty; | ||
| // A live build agent in the broker. A reset's first step is `pty-broker stop`, | ||
| // which kills it regardless of whether a workflow session is registered — so | ||
| // this is discardable work even when hasSession is false. Explicit-true only: | ||
| // a null/absent value (older or unreachable VM) must NOT flip a genuine noop. | ||
| const brokerLive = f.brokerLive === true; | ||
| // (1) Update client — ff-advance /workspace to origin tip. Changes iff behind. | ||
| const updateClient = client == null | ||
| ? { effect: 'unknown', reason: 'client state not checked' } | ||
| : client.behind > 0 | ||
| ? { effect: 'changes', reason: behindReason(client.behind, f.clientBranch) } | ||
| : { effect: 'noop', reason: 'up to date with ' + originRef(f.clientBranch) }; | ||
| // (2) Update editor — advance the editor source the binary builds from. | ||
| const updateEditor = editor == null | ||
| ? { effect: 'unknown', reason: 'editor state not checked' } | ||
| : editor.behind > 0 | ||
| ? { effect: 'changes', reason: behindReason(editor.behind, f.editorBranch) } | ||
| : { effect: 'noop', reason: 'up to date with ' + originRef(f.editorBranch) }; | ||
| // (5) Rebuild binary — advances source then compiles. Changes iff the running | ||
| // binary's build commit differs from the editor origin tip. Unknown when we | ||
| // don't have both commits to compare. | ||
| const rebuild = (editor == null || !editor.buildCommit || !editor.originSha) | ||
| ? { effect: 'unknown', reason: 'binary/source commit not checked' } | ||
| : editor.buildCommit !== editor.originSha | ||
| ? { effect: 'changes', reason: `binary at ${short(editor.buildCommit)}, ${originRef(f.editorBranch)} at ${short(editor.originSha)}` } | ||
| : { effect: 'noop', reason: 'binary built from ' + originRef(f.editorBranch) + ' tip' }; | ||
| // (3) Reset editor — discards uncommitted + session, advances source, wipes | ||
| // .codeyam. No-op ONLY when clean at origin tip with no session. | ||
| const resetEditor = editor == null | ||
| ? { effect: 'unknown', reason: 'editor state not checked' } | ||
| : (brokerLive || hasSession || editor.dirty || editor.behind > 0 || codeyamDirty) | ||
| ? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession, dirty: editor.dirty, behind: editor.behind > 0, codeyam: codeyamDirty }) } | ||
| : { effect: 'noop', reason: 'already clean at origin tip, no session' }; | ||
| // (3b) Reset client — hard-clean /workspace + .codeyam, drop the session. | ||
| // No source-advance, so `behind` is NOT part of this predicate. | ||
| const resetClient = client == null | ||
| ? { effect: 'unknown', reason: 'client state not checked' } | ||
| : (brokerLive || client.dirty || codeyamDirty || hasSession) | ||
| ? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession, dirty: client.dirty, behind: false, codeyam: codeyamDirty }) } | ||
| : { effect: 'noop', reason: 'already clean, no session' }; | ||
| // (4b) Update & Reset Everything — composite. Changes iff ANY component would | ||
| // change: rebuild-binary, reset-editor-source, or the client being | ||
| // behind/dirty. Reason summarizes which parts. | ||
| const everything = (() => { | ||
| if (editor == null || client == null) { | ||
| return { effect: 'unknown', reason: 'state not checked' }; | ||
| } | ||
| const parts = []; | ||
| if (rebuild.effect === 'changes') parts.push('rebuild'); | ||
| if (resetEditor.effect === 'changes') parts.push('reset editor'); | ||
| if (client.behind > 0 || client.dirty) parts.push('reset client'); | ||
| if (parts.length > 0) return { effect: 'changes', reason: parts.join(' + ') }; | ||
| // No part would change. But if a component was itself unverifiable, don't | ||
| // claim a confident no-op — fall back to unknown. | ||
| if (rebuild.effect === 'unknown' || resetEditor.effect === 'unknown') { | ||
| return { effect: 'unknown', reason: 'state not checked' }; | ||
| } | ||
| return { effect: 'noop', reason: 'everything already at origin tip, no session' }; | ||
| })(); | ||
| // (run-setup) Honest soft signal — there is no cheap upstream proof that | ||
| // `npm run setup` would change nothing (DB/migration state isn't | ||
| // introspected). `recommended` when a reset/update landed after the last | ||
| // successful setup; otherwise `unknown`. Never a confident green no-op. | ||
| const runSetup = f.setupRanSince | ||
| ? { effect: 'unknown', reason: 'no setup-affecting change since last setup' } | ||
| : { effect: 'recommended', reason: 'a reset/update landed since the last setup' }; | ||
| return { | ||
| 'update-client-source': updateClient, | ||
| 'update-editor-source': updateEditor, | ||
| 'reset-editor-source': resetEditor, | ||
| 'reset-client': resetClient, | ||
| 'rebuild-binary': rebuild, | ||
| 'update-reset-everything': everything, | ||
| 'run-setup': runSetup, | ||
| }; | ||
| } | ||
| module.exports = { computeButtonEffects, ACTION_IDS }; |
| // Group an already-sorted/filtered VM list by client project for the Fleet | ||
| // Dashboard's grouped layout. | ||
| // | ||
| // Returns an ORDERED array of { project, vms } groups: real project groups | ||
| // first, sorted case-insensitively by name (locale compare); the | ||
| // unknown/starting-up bucket (project === null) pinned LAST. Within each group | ||
| // the incoming order of `vms` is preserved untouched, so the caller's active | ||
| // Sort mode continues to drive within-group order — grouping never re-sorts the | ||
| // cards, it only partitions them. | ||
| // | ||
| // `projectKey(v)` resolves the group key for a VM and defaults to the same | ||
| // identity `card()` renders in the dashboard: | ||
| // (v.cardIdentity && v.cardIdentity.project) || v.project || null | ||
| // It is injectable so unit tests can drive grouping without constructing full | ||
| // card payloads. A null/empty key routes the VM into the trailing unknown | ||
| // bucket so VMs mid-provision (blank identity) never silently disappear. | ||
| // | ||
| // Pure; no I/O. | ||
| function defaultProjectKey(v) { | ||
| return (v && v.cardIdentity && v.cardIdentity.project) || (v && v.project) || null; | ||
| } | ||
| function groupVmsByProject(vms, projectKey = defaultProjectKey) { | ||
| // First pass: stable bucketing. `Map` preserves insertion order, so the | ||
| // first VM of each project fixes that group's relative position before we | ||
| // re-order — but we re-sort named groups below regardless, so insertion | ||
| // order only matters for tie-stability within equal-name groups (none here). | ||
| const named = new Map(); | ||
| let unknown = null; | ||
| for (const v of vms) { | ||
| const raw = projectKey(v); | ||
| const key = raw == null || raw === "" ? null : raw; | ||
| if (key === null) { | ||
| if (!unknown) unknown = { project: null, vms: [] }; | ||
| unknown.vms.push(v); | ||
| continue; | ||
| } | ||
| let group = named.get(key); | ||
| if (!group) { | ||
| group = { project: key, vms: [] }; | ||
| named.set(key, group); | ||
| } | ||
| group.vms.push(v); | ||
| } | ||
| const groups = Array.from(named.values()); | ||
| groups.sort((a, b) => | ||
| a.project.localeCompare(b.project, undefined, { sensitivity: "base" }) | ||
| ); | ||
| if (unknown) groups.push(unknown); | ||
| return groups; | ||
| } | ||
| module.exports = { groupVmsByProject, defaultProjectKey }; |
| 'use strict'; | ||
| // npm/fleet-job-badge.js — pure verdict for the fleet dashboard's failed-job | ||
| // badge reconciliation (improve39). A VM's last terminal-error heavy job | ||
| // (add/error, reset-all error, reset-editor error, …) used to stick on the | ||
| // dashboard card indefinitely — even after the VM was recovered and is healthy | ||
| // again — because the card renders the last job's error with no check against | ||
| // the VM's live state, so a recovered VM keeps screaming a red error and | ||
| // misleads the operator. This decides whether such a badge should be RECONCILED | ||
| // (presented as "recovered" rather than a live error) because the VM is | ||
| // demonstrably fine now. The live signals are gathered in server.js (the | ||
| // untestable server shell); the verdict lives here so the cases are unit-tested | ||
| // without a live VM. | ||
| // Should the VM's last terminal-error job badge be reconciled against live | ||
| // health — i.e. presented as "recovered" rather than a live red error? | ||
| // | ||
| // job the carried job record ({ state, action, ... }) or null | ||
| // reachable the VM's editor answered this poll (true = healthy / responding) | ||
| // drifted a project-dir-drift signal is present (reachable-but-unwired / | ||
| // half-provisioned add — genuinely needs-recovery, owned by the | ||
| // companion reconcile-inflight-jobs plan; never falsely cleared here) | ||
| // offBranch the VM's live client branch differs from its target branch | ||
| // degraded the job completed but a wiring sub-step is flagged degraded | ||
| // | ||
| // Returns true only when the job is a terminal error AND the VM is demonstrably | ||
| // fine (reachable, not drifted, on-branch, not degraded). An unreachable / | ||
| // drifted / off-branch VM keeps its error surfaced — a real problem must stay | ||
| // visible. | ||
| function shouldReconcileFailedBadge({ job, reachable, drifted, offBranch, degraded } = {}) { | ||
| if (!job || job.state !== 'error') return false; | ||
| if (!reachable) return false; | ||
| if (drifted) return false; | ||
| if (offBranch) return false; | ||
| if (degraded) return false; | ||
| return true; | ||
| } | ||
| module.exports = { shouldReconcileFailedBadge }; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
| import{j as c}from"./markdown-v0F0UEt3.js";import{b as o}from"./react-CSS0HapR.js";import{S as m}from"./ScenarioDataPanel-nuLXJzLf.js";import"./useEvents-DggWrtHe.js";import"./xterm--24IGk-x.js";import"./index-KDFAgt2e.js";function S({slug:t}){const[r,i]=o.useState(void 0);return o.useEffect(()=>{let n=!1;return fetch(`/api/scenarios/${encodeURIComponent(t)}`).then(e=>e.ok?e.json():null).then(e=>{if(n||!e)return;const a=typeof e=="object"&&e!==null&&"name"in e?String(e.name):void 0;i(a),a&&(document.title=`${a} · data`)}).catch(()=>{}),()=>{n=!0}},[t]),c.jsx(m,{slug:t,scenarioName:r,variant:"fullPage"})}export{S as ScenarioDataPanelFullPage}; |
Sorry, the diff of this file is too big to display
+133
-52
@@ -36,3 +36,2 @@ #!/usr/bin/env node | ||
| ensureBinary, | ||
| isSelfHostTarget, | ||
| rootDir, | ||
@@ -94,24 +93,23 @@ killChildProcess, | ||
| * Decide whether the dev wrapper should rebuild the Rust binary (and the UI, | ||
| * for the launcher path) before forwarding to it. Pure: takes injected cwd / | ||
| * repoRoot / env so the four-way decision is testable without spawning cargo | ||
| * or reading process.env. | ||
| * for the launcher path) before forwarding to it. The answer is always yes: | ||
| * `codeyam-editor-dev` ALWAYS rebuilds before running. Running fresh source is | ||
| * the entire purpose of the dev wrapper — if a developer wants a frozen tool | ||
| * that never rebuilds, the cargo-installed `codeyam-editor` binary is exactly | ||
| * that. | ||
| * | ||
| * The wrapper rebuilds in two cases: | ||
| * - The target IS the editor repo itself (developing the editor on its | ||
| * own checkout). This is the canonical self-host case. | ||
| * - `CODEYAM_DEV_REBUILD=1` is explicitly set (the documented opt-in for | ||
| * "editing the editor source while running it against an external client | ||
| * project", named in editor-dev.js's earlier history). | ||
| * `cargo build` is idempotent: a sub-second no-op that does not relink when | ||
| * nothing changed. So always-building costs nothing for a developer who only | ||
| * uses the editor as a tool against their own app (the binary never changes → | ||
| * the server-side stale-binary watcher never fires → zero mid-session | ||
| * restarts), and produces a rebuild+restart only when editor source actually | ||
| * changed — which is exactly when the developer wants the new code live. | ||
| * | ||
| * Otherwise — the common case where a developer uses the editor as a tool | ||
| * against their own app — the wrapper skips the rebuild entirely. The | ||
| * existing binary runs unchanged, and the server-side stale-binary watcher | ||
| * (also gated on self-host) never SIGUSR1's. The combined effect is what the | ||
| * plan calls out: an external-project session is never disrupted by a bare | ||
| * commit, a fleet rebase, or any other event that would have relinked the | ||
| * binary without changing what runs. | ||
| * The injected cwd / repoRoot / env are retained so the contract — "rebuild | ||
| * regardless of where you run it or what env is set" — is demonstrable in a | ||
| * unit test across the dimensions that used to gate the decision (self-host | ||
| * vs. external project, `CODEYAM_DEV_REBUILD` set or not). They no longer | ||
| * influence the result. | ||
| */ | ||
| function shouldRebuildOnPassthrough({ cwd, repoRoot, env }) { | ||
| if (env.CODEYAM_DEV_REBUILD === "1") return true; | ||
| return isSelfHostTarget(cwd, repoRoot); | ||
| function shouldRebuildOnPassthrough() { | ||
| return true; | ||
| } | ||
@@ -159,2 +157,8 @@ | ||
| * direct `codeyam-editor start` calls; this launcher composes init + start. | ||
| * | ||
| * Intentional divergence (improve39): this launcher auto-inits ALL `none` | ||
| * projects (empty or existing-source); the Rust `start_preflight` now | ||
| * auto-inits only the EMPTY case and still bails (exit 2) on existing-source. | ||
| * That gap is pre-existing and deliberate — the dev launcher is a local | ||
| * convenience, not the direct-`start` contract. | ||
| */ | ||
@@ -187,12 +191,18 @@ function planPreflightAction(classification) { | ||
| * legacy already bailed earlier) | ||
| * action "init", wasEmpty → /codeyam-editor scaffold-from-scratch | ||
| * action "init", wasEmpty → greenfield message. /codeyam-editor is driven | ||
| * from inside the browser UI, never hand-invoked, | ||
| * so the wording follows `browserOpened`: if the | ||
| * launcher actually opened a tab, promise it; if | ||
| * not (e.g. --restart attach), point at the URL | ||
| * so the user still has a path forward. The | ||
| * banner must never promise a tab that didn't | ||
| * appear. | ||
| * action "init", !wasEmpty → /codeyam-onboard migrate-existing | ||
| */ | ||
| function postLaunchInitBanner({ preflightAction, wasEmpty }) { | ||
| function postLaunchInitBanner({ preflightAction, wasEmpty, browserOpened, url }) { | ||
| if (preflightAction !== "init") return null; | ||
| if (wasEmpty) { | ||
| return ( | ||
| "\nInitialized .codeyam/ in this directory. Open Claude Code here and " + | ||
| "run `/codeyam-editor` to scaffold a new project from scratch." | ||
| ); | ||
| return browserOpened | ||
| ? "\nA browser window is opening where you can begin your project." | ||
| : `\nOpen ${url} in your browser to begin your project.`; | ||
| } | ||
@@ -205,2 +215,39 @@ return ( | ||
| /** | ||
| * Decide whether the launcher should open a browser tab to the editor UI. | ||
| * Pure (no fs, no spawn) so the gating is unit-testable without a binary, | ||
| * mirroring `planPreflightAction` / `postLaunchInitBanner`. | ||
| * | ||
| * The browser open belongs ONLY on a genuine cold start — bringing the | ||
| * editor server up fresh against a not-yet-running port. Two cases must | ||
| * NOT re-pop a tab, because one is presumably already open: | ||
| * | ||
| * shouldRestart → false `--restart` (rebuild-self, restart-server routed | ||
| * through this wrapper, a manual --restart) tears | ||
| * down and respawns a server the user is already | ||
| * looking at. Re-opening spams tabs — the reported | ||
| * symptom. Mirrors the Rust `--no-open` restart | ||
| * spawn in restart_server.rs. | ||
| * wasRunning → false `ensureServer` attached to an already-running | ||
| * same-project server (returned null) — no fresh | ||
| * process, so no new tab. | ||
| * | ||
| * Only a fresh spawn on a cold start (neither restart nor attach) opens the | ||
| * UI, matching the Rust `no_open`-gated `open_url` in start.rs. | ||
| * | ||
| * freshInit → true A brand-new empty project that was just init'd | ||
| * this run. The entire greenfield onboarding flow | ||
| * is driven from inside the editor UI, and a | ||
| * project that didn't exist a moment ago has no | ||
| * pre-existing tab for the --restart tab-spam guard | ||
| * to protect. So a fresh init opens regardless of | ||
| * `shouldRestart` — UNLESS we merely attached to an | ||
| * already-running server (`wasRunning`), which | ||
| * implies a tab is already up. | ||
| */ | ||
| function shouldOpenBrowser({ shouldRestart, wasRunning, freshInit }) { | ||
| if (freshInit) return !wasRunning; | ||
| return !shouldRestart && !wasRunning; | ||
| } | ||
| module.exports = { | ||
@@ -214,2 +261,3 @@ hasSubcommand, | ||
| postLaunchInitBanner, | ||
| shouldOpenBrowser, | ||
| }; | ||
@@ -220,15 +268,9 @@ | ||
| if (hasSubcommand()) { | ||
| // Only rebuild when the target IS the editor repo itself, or when the | ||
| // developer has explicitly opted into the "edit editor against external | ||
| // project" workflow via CODEYAM_DEV_REBUILD=1. On any other project the | ||
| // editor binary is a fixed tool and a `cargo build` here ripples into a | ||
| // stale-binary SIGUSR1 re-exec that resets the user's session for no | ||
| // reason. `ensureBinary()` still builds once if no binary exists at all. | ||
| if ( | ||
| shouldRebuildOnPassthrough({ | ||
| cwd: process.cwd(), | ||
| repoRoot: rootDir, | ||
| env: process.env, | ||
| }) | ||
| ) { | ||
| // Always rebuild before forwarding — running fresh source is the whole | ||
| // point of the dev wrapper. `cargo build` is a sub-second no-op when | ||
| // nothing changed (no relink → the server-side stale-binary watcher never | ||
| // fires), and relinks only when editor source actually changed, which is | ||
| // exactly when the developer wants the new code live. `ensureBinary()` | ||
| // still builds once if no binary exists at all. | ||
| if (shouldRebuildOnPassthrough()) { | ||
| enforceFreshBinary(buildBinary(), { action: "run editor commands" }); | ||
@@ -310,15 +352,10 @@ } | ||
| // Same gate as the passthrough path: only rebuild the Rust binary + | ||
| // UI when the launcher target IS the editor repo itself, or when | ||
| // CODEYAM_DEV_REBUILD=1 is explicitly set. Launching the editor | ||
| // against an external project must not relink the binary — the | ||
| // server-side stale-binary watcher would otherwise SIGUSR1-restart | ||
| // the freshly-launched server immediately. `ensureBinary()` still | ||
| // Always rebuild the Rust binary + UI before launching — same contract | ||
| // as the passthrough path. The build runs BEFORE the server (re)starts | ||
| // below, so the new server records its start time after the relink and | ||
| // the stale-binary watcher reads it as fresh (no immediate SIGUSR1). A | ||
| // no-op `cargo build` (nothing changed) doesn't relink, so an external | ||
| // client-project launch is never disrupted. `ensureBinary()` still | ||
| // builds once if no binary exists. | ||
| const shouldRebuildLaunch = shouldRebuildOnPassthrough({ | ||
| cwd: projectDir, | ||
| repoRoot: rootDir, | ||
| env: process.env, | ||
| }); | ||
| if (shouldRebuildLaunch) { | ||
| if (shouldRebuildOnPassthrough()) { | ||
| enforceFreshBinary(buildBinary(), { | ||
@@ -367,6 +404,26 @@ action: "run editor commands", | ||
| console.error(`Project: ${projectDir}`); | ||
| open(url); | ||
| // Open the browser only on a genuine cold start. On the --restart path | ||
| // (rebuild-self, restart-server, manual --restart) and when we attached | ||
| // to an already-running server, a tab is presumably already open — the | ||
| // `Editor UI: <url>` line above is the signal. `ensureServer` returns | ||
| // null when it attached rather than spawning, so that is `wasRunning`. | ||
| // Exception: a fresh greenfield init (`freshInit`) opens regardless of | ||
| // --restart, because a project that didn't exist a moment ago has no | ||
| // pre-existing tab to protect and the onboarding flow lives in the UI. | ||
| // Compute the open decision once and feed the SAME boolean into the | ||
| // banner so it can never promise a tab that didn't appear. | ||
| const freshInit = preflight.action === "init" && wasEmpty; | ||
| const openedBrowser = shouldOpenBrowser({ | ||
| shouldRestart, | ||
| wasRunning: serverChild === null, | ||
| freshInit, | ||
| }); | ||
| if (openedBrowser) { | ||
| open(url); | ||
| } | ||
| const banner = postLaunchInitBanner({ | ||
| preflightAction: preflight.action, | ||
| wasEmpty, | ||
| browserOpened: openedBrowser, | ||
| url, | ||
| }); | ||
@@ -378,3 +435,8 @@ if (banner) { | ||
| // Track launcher-initiated teardown so the unexpected-exit handler below | ||
| // can tell "we killed it on SIGINT" from "the server died on its own". | ||
| let tearingDown = false; | ||
| process.on("SIGINT", () => { | ||
| tearingDown = true; | ||
| clearInterval(keepAlive); | ||
@@ -384,2 +446,21 @@ killChildProcess(serverChild); | ||
| }); | ||
| // Defense-in-depth against orphaned launchers: if the spawned server | ||
| // child exits and we did NOT initiate it (SIGINT), clear keepAlive and | ||
| // exit rather than lingering as a node process holding a dead child | ||
| // handle. We deliberately do NOT respawn — an exited server should let | ||
| // the launcher exit cleanly so stale supervisors can't accumulate across | ||
| // repeated launches. The in-place SIGUSR1 re-exec keeps the SAME pid, so | ||
| // it does NOT emit an "exit" here and does not trip this path; only a | ||
| // genuinely dead server does. `serverChild` is null when we attached to | ||
| // an already-running server (nothing of ours to watch). | ||
| if (serverChild) { | ||
| serverChild.on("exit", (code) => { | ||
| if (tearingDown) { | ||
| return; | ||
| } | ||
| clearInterval(keepAlive); | ||
| process.exit(code ?? 1); | ||
| }); | ||
| } | ||
| }; | ||
@@ -386,0 +467,0 @@ |
+35
-17
@@ -85,13 +85,15 @@ #!/usr/bin/env node | ||
| // Install target/debug/codeyam-editor to <cargo_bin>/codeyam-editor | ||
| // atomically (write to tmp in same dir, then rename), and refresh the | ||
| // codeyam-editor-pty-broker hardlink so it points at the new inode. | ||
| // Install target/debug/codeyam-editor to <cargo_bin>/codeyam-editor and | ||
| // the dedicated target/debug/codeyam-editor-pty-broker to | ||
| // <cargo_bin>/codeyam-editor-pty-broker, both atomically (write to a tmp | ||
| // in the same dir, then rename). | ||
| // | ||
| // The PTY broker auto-spawn (control-api/src/pty_broker_startup.rs) always | ||
| // resolves the *installed* path, NOT current_exe() — that was a deliberate | ||
| // fix to prevent a fork-bomb where test binaries spawned themselves. So a | ||
| // fresh `target/debug/codeyam-editor` is not enough; the install path must | ||
| // be refreshed too. Without this, build tabs hang on "Reconnecting..." | ||
| // because the broker daemon errors out (`unrecognized subcommand`) before | ||
| // the UDS socket opens. | ||
| // The broker is now its OWN binary (its own inode), NOT a hardlink of the | ||
| // monolith — a dedicated executable file is what gives the long-lived | ||
| // broker daemon a stable, honest process name on every platform | ||
| // (especially macOS, where hardlink aliases share one vnode name cache | ||
| // and bleed across roles). The PTY broker auto-spawn | ||
| // (control-api/src/pty_broker_startup.rs) prefers this dedicated binary | ||
| // next to the installed monolith; a stale or missing copy is what made | ||
| // build tabs hang on "Reconnecting...", so both must be refreshed here. | ||
| function installBinary() { | ||
@@ -105,3 +107,2 @@ const srcPath = path.join(rootDir, "target", "debug", "codeyam-editor"); | ||
| const dstPath = path.join(binDir, "codeyam-editor"); | ||
| const linkPath = path.join(binDir, "codeyam-editor-pty-broker"); | ||
| const tmpPath = path.join(binDir, `.codeyam-editor.tmp.${process.pid}`); | ||
@@ -112,12 +113,29 @@ console.error(`Installing fresh binary to ${dstPath}...`); | ||
| linkOrCopyBinary(srcPath, dstPath, tmpPath); | ||
| try { | ||
| fs.unlinkSync(linkPath); | ||
| } catch (e) { | ||
| if (e.code !== "ENOENT") throw e; | ||
| } | ||
| fs.linkSync(dstPath, linkPath); | ||
| } catch (err) { | ||
| console.error(`Failed to install binary: ${err.message}`); | ||
| try { fs.unlinkSync(tmpPath); } catch {} | ||
| return; | ||
| } | ||
| // Dedicated broker binary — install as its own file (own inode), not a | ||
| // hardlink. Best-effort: if cargo didn't build it (older workspace), | ||
| // the broker auto-spawn falls back to the monolith `editor pty-broker | ||
| // daemon` shim, so a missing broker binary is a warning, not fatal. | ||
| const brokerSrc = path.join(rootDir, "target", "debug", "codeyam-editor-pty-broker"); | ||
| const brokerDst = path.join(binDir, "codeyam-editor-pty-broker"); | ||
| const brokerTmp = path.join(binDir, `.codeyam-editor-pty-broker.tmp.${process.pid}`); | ||
| if (!fs.existsSync(brokerSrc)) { | ||
| console.error( | ||
| `Note: ${brokerSrc} not found — broker will fall back to the monolith shim. ` + | ||
| `Run \`cargo build -p codeyam-pty-broker\` to build it.` | ||
| ); | ||
| return; | ||
| } | ||
| console.error(`Installing dedicated broker binary to ${brokerDst}...`); | ||
| try { | ||
| linkOrCopyBinary(brokerSrc, brokerDst, brokerTmp); | ||
| } catch (err) { | ||
| console.error(`Failed to install broker binary: ${err.message}`); | ||
| try { fs.unlinkSync(brokerTmp); } catch {} | ||
| } | ||
| } | ||
@@ -124,0 +142,0 @@ |
+189
-16
@@ -79,18 +79,26 @@ 'use strict'; | ||
| // Decide what the startup reap should do for one sidecar. Pure: takes the | ||
| // decoded sidecar + GCP-existence verdict + (for adds) container-up verdict, | ||
| // returns a tag the caller dispatches on. | ||
| // verdict when dispatch | ||
| // 'cleanup' destroy gone, OR add gone runDestroyCleanup / runAddCleanup | ||
| // 'reconcile-add' add up AND container up ensure roster + recordLaunch, then clear | ||
| // 'recover-bootstrap' add instance up but container missing/down re-run cloud:up to finish the half-done bootstrap | ||
| // 'clear-only' destroy sidecar but instance still exists just clear the sidecar | ||
| // 'leave' gcloud uncertain (exists === null) leave for the next boot | ||
| // The (exists, containerUp) split is what distinguishes a finished add from a | ||
| // half-provisioned one: a dashboard SIGKILL'd mid-`cloud:up` leaves a GCP | ||
| // decoded sidecar + GCP-existence verdict + (for adds) container-up verdict + | ||
| // (for up adds) project-wiring verdict, returns a tag the caller dispatches on. | ||
| // verdict when dispatch | ||
| // 'cleanup' destroy gone, OR add gone runDestroyCleanup / runAddCleanup | ||
| // 'reconcile-add' add up, container up, project wired (or unknown) ensure roster + recordLaunch, then clear | ||
| // 'half-provisioned' add up, container up, but project NOT wired surface needs-recovery card (finish-wire from cfg) | ||
| // 'recover-bootstrap' add instance up but container missing/down re-run cloud:up to finish the half-done bootstrap | ||
| // 'clear-only' destroy sidecar but instance still exists just clear the sidecar | ||
| // 'leave' gcloud uncertain (exists === null) leave for the next boot | ||
| // The (exists, containerUp) split distinguishes a finished add from a container- | ||
| // down half-provisioned one: a dashboard SIGKILL'd mid-`cloud:up` leaves a GCP | ||
| // instance with no docker/container, which the old (exists-only) matrix wrongly | ||
| // classified 'reconcile-add' — adding an unreachable VM to the roster. | ||
| // `containerUp` is only consulted for an add whose instance exists; destroy and | ||
| // the instance-gone add path ignore it. Splitting the matrix out lets the table | ||
| // be exhaustively tested without any of the side-effects in server.js. | ||
| function reapVerdict({ action, exists, containerUp }) { | ||
| // classified 'reconcile-add' — adding an unreachable VM to the roster. The | ||
| // `projectWired` axis closes the remaining gap (improve39 VM-7): an instance + | ||
| // container that ARE up but whose `/workspace` is unwired (no git remote, empty | ||
| // manifest) is NOT a finished add — it's half-provisioned and must surface a | ||
| // recoverable card, not be silently reconciled "done". Only a POSITIVE unwired | ||
| // signal (`projectWired === false`) downgrades to 'half-provisioned'; an | ||
| // unknown/unprobed wiring (`null`/`undefined`) stays 'reconcile-add' so a flaky | ||
| // probe can't strand a healthy VM. `containerUp`/`projectWired` are only consulted | ||
| // for an add whose instance exists; destroy and the instance-gone add path ignore | ||
| // them. Splitting the matrix out lets the table be exhaustively tested without any | ||
| // of the side-effects in server.js. | ||
| function reapVerdict({ action, exists, containerUp, projectWired }) { | ||
| if (exists === null) return 'leave'; | ||
@@ -100,3 +108,4 @@ if (action === 'destroy') return exists ? 'clear-only' : 'cleanup'; | ||
| if (!exists) return 'cleanup'; | ||
| return containerUp ? 'reconcile-add' : 'recover-bootstrap'; | ||
| if (!containerUp) return 'recover-bootstrap'; | ||
| return projectWired === false ? 'half-provisioned' : 'reconcile-add'; | ||
| } | ||
@@ -106,2 +115,98 @@ return 'leave'; | ||
| // Classify whether a VM's editor `/workspace` is actually wired to a client | ||
| // project, from a best-effort probe of two signals: the git remote URL and | ||
| // whether `package.json` carries a real `name`. Pure: server.js runs the SSH + | ||
| // `docker exec` probe and hands the raw stdout/err here so the decision is | ||
| // unit-testable without a live VM. | ||
| // - err present (ssh/docker blip, timeout) → null (unknown — do NOT | ||
| // strand a healthy VM on a flaky probe; reapVerdict treats null as wired) | ||
| // - a git remote OR a non-empty package.json name → true (wired) | ||
| // - neither (empty / no-git workspace) → false (half-provisioned — | ||
| // the VM-7 signature: `/workspace` with no remote and an empty manifest) | ||
| // The probe emits the two signals separated by the `---CY---` sentinel so this | ||
| // parser is independent of locale/line-ending noise in the ssh transport. | ||
| function classifyProjectWiring({ stdout, err } = {}) { | ||
| if (err) return null; | ||
| const parts = String(stdout == null ? '' : stdout).split('---CY---').map((s) => s.trim()); | ||
| const remote = parts[0] || ''; | ||
| const nameCount = Number((parts[1] || '0').trim()) || 0; | ||
| if (remote || nameCount > 0) return true; | ||
| return false; | ||
| } | ||
| // Durable exit marker for a detached `add` provision (improve40). The add's | ||
| // `cloud:up` shell is spawned in its OWN session (detached) so a dashboard | ||
| // SIGKILL — the 2GB launchd hard cap, exactly the kill that aborted the VM-7 | ||
| // add mid-flight — no longer reaches it; the provision keeps running. When that | ||
| // detached shell finally exits it writes `add-<n>.exit` (its trailing exit code | ||
| // + an ISO timestamp) into PENDING_JOBS_DIR, alongside the `add-<n>.json` | ||
| // sidecar. The marker is the GROUND-TRUTH completion signal the next boot reads | ||
| // instead of inferring `in-flight → error` from the lost child: a marker means | ||
| // "the provision finished while the dashboard was away," its code says whether | ||
| // it finished OK. The name is derived here so the shell `trap` (server.js) and | ||
| // the JS reader build the exact same path. | ||
| function exitMarkerName(n) { | ||
| return `add-${n}.exit`; | ||
| } | ||
| // Parse an exit-marker file body into { exitCode, finishedAt }, or null when the | ||
| // body is missing/blank/malformed. Format is two lines — the numeric exit code | ||
| // then an ISO timestamp — written by the shell trap. A non-integer first line is | ||
| // treated as no-marker (null) so a half-written / corrupt marker falls back to | ||
| // the gcloud reconcile path rather than being read as a bogus exit code. Pure: | ||
| // takes the text so it's unit-testable without touching disk. | ||
| function parseExitMarker(text) { | ||
| if (text == null) return null; | ||
| const lines = String(text).split('\n').map((s) => s.trim()).filter(Boolean); | ||
| if (!lines.length) return null; | ||
| const code = Number(lines[0]); | ||
| if (!Number.isInteger(code)) return null; | ||
| return { exitCode: code, finishedAt: lines[1] || null }; | ||
| } | ||
| // Read VM-n's exit marker from `dir`, or null when it's absent/unreadable/ | ||
| // malformed (the still-running and never-written cases both surface as null). | ||
| function readExitMarker(dir, n) { | ||
| try { return parseExitMarker(fs.readFileSync(path.join(dir, exitMarkerName(n)), 'utf8')); } | ||
| catch { return null; } | ||
| } | ||
| // Remove a consumed exit marker; a missing file is the already-cleared path, not | ||
| // an error (mirrors clearPendingJob). | ||
| function clearExitMarker(dir, n) { | ||
| try { fs.unlinkSync(path.join(dir, exitMarkerName(n))); } | ||
| catch (_) { /* missing is fine */ } | ||
| } | ||
| // Decide how a snapshot-restored `add` should be re-attached after a dashboard | ||
| // restart, BEFORE any gcloud probe. Pure: takes the durable signals the boot | ||
| // path gathers — whether the exit marker exists, its recorded code, and whether | ||
| // the detached provision child is still alive (recorded pid, `kill -0`). | ||
| // verdict when dispatch | ||
| // 'done' marker present, exit code 0 roster + recordLaunch, clear sidecar/marker | ||
| // 'error' marker present, non-zero exit code surface the real failure, clear sidecar/marker | ||
| // 'running' no marker yet, child still alive RE-ATTACH: keep polling, do NOT mark error | ||
| // 'reconcile' child gone, no usable marker fall through to fc.reapVerdict (gcloud probe) | ||
| // This is the durable fix's brain: a provision that survived the kill (still | ||
| // running, or finished while we were away) is classified from GROUND TRUTH, so | ||
| // it is never blanket-converted `in-flight → error` the way the snapshot-only | ||
| // path did (the improve39 VM-7 false `add-failed`). Only when the child is gone | ||
| // AND left no parseable marker do we fall back to inferring state from gcloud — | ||
| // `reconcile` hands that exact (exists, containerUp, projectWired) decision to | ||
| // fc.reapVerdict. A marker whose first line isn't an integer is treated as | ||
| // absent (parseExitMarker → null) so a corrupt marker reconciles rather than | ||
| // asserting a bogus code. The matrix is exhaustively tested without a live VM. | ||
| function reattachVerdict({ markerPresent, exitCode, childAlive } = {}) { | ||
| if (markerPresent) { | ||
| // Guard the null/undefined coercion BEFORE Number() — `Number(null)` is 0, | ||
| // which would masquerade as a clean exit. A marker with no parseable code | ||
| // (half-written / corrupt) must reconcile, not assert success. | ||
| const code = exitCode == null ? NaN : Number(exitCode); | ||
| if (Number.isInteger(code)) return code === 0 ? 'done' : 'error'; | ||
| return 'reconcile'; // marker present but no parseable code — let gcloud decide | ||
| } | ||
| if (childAlive) return 'running'; | ||
| return 'reconcile'; | ||
| } | ||
| // Anti-loop budget for the `recover-bootstrap` path. Pure: given the count of | ||
@@ -119,2 +224,62 @@ // recoveries ALREADY persisted on the sidecar, return the next attempt number, | ||
| // improve40 — select the orphaned `add` jobs that must be reconciled from the | ||
| // VM's REAL provision state even though their per-job sidecar is GONE. The | ||
| // durable re-attach path (sidecar + exit marker + recorded pid) cannot fire for | ||
| // these — a restart left only the snapshot record — so the snapshot job's | ||
| // stashed `config` is the sole cfg source and a live gcloud probe is the only | ||
| // ground truth. Pure: takes the restored jobs map + the set of `<action>-<n>` | ||
| // sidecar keys the sidecar reap already owns; returns the [{ n, cfg }] the boot | ||
| // path hands to reconcileViaGcloud. A job is selected when ALL hold: | ||
| // - it is an `add`, | ||
| // - it is flagged `restartOrphaned` (restoreFromSnapshot downgraded a | ||
| // running add → error because the dashboard restarted before it finished — | ||
| // this is the ONLY error state we re-probe; an add that failed IN-PROCESS | ||
| // carries a plain terminal `error` with no flag and stays surfaced), AND | ||
| // - NO `add-<n>` sidecar exists (a sidecar means the sidecar reap already | ||
| // owns it — never double-reconcile). | ||
| // `cfg` rides from the job's `config`; null when the snapshot never carried one | ||
| // (older snapshot) — the caller still probes, but a roster reconcile then can't | ||
| // recordLaunch, exactly mirroring the sidecar path's `meta.cfg`-absent case. | ||
| function orphanedAddsToReconcile({ jobs, sidecarKeys } = {}) { | ||
| const sidecars = sidecarKeys instanceof Set ? sidecarKeys : new Set(sidecarKeys || []); | ||
| const src = (jobs && typeof jobs === 'object') ? jobs : {}; | ||
| const out = []; | ||
| for (const [k, j] of Object.entries(src)) { | ||
| if (!j || typeof j !== 'object') continue; | ||
| if (j.action !== 'add') continue; | ||
| if (!j.restartOrphaned) continue; | ||
| if (sidecars.has(`add-${k}`)) continue; | ||
| out.push({ n: Number(k), cfg: j.config || null }); | ||
| } | ||
| return out; | ||
| } | ||
| // improve41 — the destroy-direction mirror of orphanedAddsToReconcile: decide | ||
| // what to do with a VM that is STILL in the roster but whose GCP instance state | ||
| // we have just re-probed. The bug this closes: a Destroy that races a dashboard | ||
| // restart can leave the instance deleted while the startup reap took the | ||
| // `clear-only` path (instance still present at reap time) and dropped the only | ||
| // sidecar — so nothing ever runs roster cleanup and the VM polls forever | ||
| // "unreachable." A sidecar-independent reconcile, keyed on REAL GCP existence | ||
| // rather than on a pending-job sidecar, catches it. Pure: server.js runs the | ||
| // gcloud describe (→ exists, via classifyDescribeError) and reads session / | ||
| // pending-add state, and hands the three facts here so the matrix is testable | ||
| // without a live VM. | ||
| // verdict when dispatch | ||
| // 'cleanup' instance gone AND the VM is idle runDestroyCleanup | ||
| // 'leave' exists uncertain (null), OR the VM is busy do nothing this pass | ||
| // 'keep' instance still exists do nothing (healthy) | ||
| // Fail-safe by construction: cleanup fires ONLY on a DEFINITIVE absence | ||
| // (exists === false) — a flaky/transient describe (null) is always 'leave', so a | ||
| // gcloud blip can never evict a healthy VM (mirroring reapVerdict's `leave` | ||
| // posture). "Busy" (a live session or an in-flight add to this number) also | ||
| // holds the entry for an operator rather than auto-removing — the shouldn't- | ||
| // happen-but-fail-safe case where a vanished instance still reports a session. | ||
| function reconcileRosteredVm({ exists, hasSession, hasPendingAdd } = {}) { | ||
| if (exists === null || exists === undefined) return "leave"; | ||
| if (exists) return "keep"; | ||
| if (hasSession || hasPendingAdd) return "leave"; | ||
| return "cleanup"; | ||
| } | ||
| module.exports = { | ||
@@ -127,3 +292,11 @@ parsePendingJobFilename, | ||
| reapVerdict, | ||
| reconcileRosteredVm, | ||
| orphanedAddsToReconcile, | ||
| classifyProjectWiring, | ||
| recoverBootstrapDecision, | ||
| exitMarkerName, | ||
| parseExitMarker, | ||
| readExitMarker, | ||
| clearExitMarker, | ||
| reattachVerdict, | ||
| }; |
+35
-5
@@ -77,7 +77,15 @@ 'use strict'; | ||
| * repopulate from real data). Otherwise returns { jobs, vms, orphans } | ||
| * with two safety transforms applied to jobs: | ||
| * with these safety transforms applied to jobs: | ||
| * | ||
| * • `running` → `error` with a log marker, because the child process | ||
| * died with the dashboard; we can't possibly have a live job tracker | ||
| * after a process restart. | ||
| * • `running` WITH a pending-job sidecar → `recovering` with a deferral | ||
| * marker, NOT `error`. A heavy job that stashed a recoverable sidecar | ||
| * (its cfg) must defer its final state to the startup reap, which | ||
| * actually checks gcloud — the blanket `error` was the improve39 VM-7 | ||
| * bug: a mid-add restart stranded an instance that was really up as a | ||
| * dead `add-failed`. `recovering` is an honest "the reap will resolve | ||
| * this" surface; the reap then supersedes it (reconcile/half-provisioned | ||
| * /cleanup) once it has the gcloud verdict. | ||
| * • `running` WITHOUT a sidecar → `error` with a log marker, because the | ||
| * child process died with the dashboard and there's nothing to recover | ||
| * from — un-recoverable history, the old behavior. | ||
| * • `queued` → dropped, because the in-memory pendingJobQueue array | ||
@@ -87,4 +95,9 @@ * is gone, so promoteFromJobQueue can never start them. The operator | ||
| * | ||
| * `sidecarKeys` is the set of `${action}-${n}` strings the caller read from | ||
| * the pending-jobs dir (server.js passes it; tests pass a plain array/Set). | ||
| * Omitted → no job has a sidecar → every running job downgrades to `error` | ||
| * exactly as before, so existing callers/tests are unaffected. | ||
| * | ||
| * Pure; no I/O. */ | ||
| function restoreFromSnapshot({ snapshot, maxAgeMs, nowIso } = {}) { | ||
| function restoreFromSnapshot({ snapshot, maxAgeMs, nowIso, sidecarKeys } = {}) { | ||
| if (!snapshot || typeof snapshot !== 'object') return null; | ||
@@ -100,2 +113,3 @@ if (typeof snapshot.timestamp !== 'string') return null; | ||
| const sidecars = sidecarKeys instanceof Set ? sidecarKeys : new Set(sidecarKeys || []); | ||
| const srcJobs = (snapshot.jobs && typeof snapshot.jobs === 'object') ? snapshot.jobs : {}; | ||
@@ -107,2 +121,10 @@ const jobs = {}; | ||
| if (j.state === 'running') { | ||
| if (sidecars.has(`${j.action}-${k}`)) { | ||
| jobs[k] = { | ||
| ...j, | ||
| state: 'recovering', | ||
| log: `${j.log ? j.log + '\n' : ''}(snapshot-restored — deferring to startup reap)`, | ||
| }; | ||
| continue; | ||
| } | ||
| jobs[k] = { | ||
@@ -112,2 +134,10 @@ ...j, | ||
| finishedAt: nowIso, | ||
| // improve40: mark this as a restart-orphan, NOT a genuine in-process | ||
| // failure. The job was `running` when the restart downgraded it and has | ||
| // NO sidecar to reconcile from — so the boot path must probe the VM's | ||
| // REAL provision state (orphanedAddsToReconcile → gcloud reap) rather | ||
| // than leave a dead `add-failed` badge on a VM that may have actually | ||
| // provisioned (the VM-7 hole). An add that failed IN-PROCESS keeps its | ||
| // own terminal `error` with no flag, so it is never re-probed/cleared. | ||
| restartOrphaned: true, | ||
| log: `${j.log ? j.log + '\n' : ''}(snapshot-restored — dashboard restarted before this finished)`, | ||
@@ -114,0 +144,0 @@ }; |
+94
-16
@@ -34,14 +34,45 @@ 'use strict'; | ||
| // | ||
| // Between the cp-asides and the `rm -rf` we cooperatively pause the editor | ||
| // server (`stop-server --hold-for-restart`). Without that, the server keeps | ||
| // writing to `.codeyam/logs/editor-server.log` and `.codeyam/run/` between | ||
| // when `rm -rf` enumerates the dir and when it tries to `rmdir` the empty | ||
| // directory — it surfaces as `rm: cannot remove '.codeyam': Directory not | ||
| // empty`, which the dashboard reports as `error`. SIGUSR2 closes listeners + | ||
| // stops the dev-server and parks the PID; the parent script's trailing | ||
| // `restart-server --force-in-container` then resurrects via the existing | ||
| // SIGUSR1 in-place reexec. Fail-soft `|| true` so a server that's already | ||
| // dead (e.g. the reset is rerun after a partial failure) doesn't block the | ||
| // chain. | ||
| // Before the wipe we QUIESCE ALL WRITERS: park the editor server | ||
| // (`stop-server --hold-for-restart`) AND stop the PTY broker | ||
| // (`pty-broker stop`). Parking only the editor server is insufficient — the | ||
| // broker is a SEPARATE process (and hooks / agent subprocesses can fire), so | ||
| // it keeps re-creating `.codeyam/logs/` and `.codeyam/run/` between when the | ||
| // wipe enumerates the dir and when it removes it. That race surfaced live | ||
| // (VM-1, 2026-06-01) as `rm: cannot remove '.codeyam': Directory not empty`, | ||
| // aborting the whole reset. Both quiesce calls are fail-soft `|| true` so a | ||
| // server/broker that's already dead (e.g. the reset is rerun after a partial | ||
| // failure) doesn't block the chain. | ||
| // | ||
| // The wipe itself is RETRY-RESILIENT instead of a single `rm -rf .codeyam` | ||
| // that aborts the &&-chain on one transient re-creation: a bounded loop of | ||
| // `find .codeyam -mindepth 1 -delete` empties the contents and tolerates a | ||
| // brief re-create, then proceeds even if `.codeyam` is still non-empty after | ||
| // best-effort (a soft retry, never a hard failure — `git checkout`/`init` | ||
| // repopulates over whatever remains). Emptying contents (not removing the dir) | ||
| // also sidesteps the `rmdir` race entirely. | ||
| // | ||
| // The destructive region (park → broker-stop → wipe → restore) is made | ||
| // FAILURE-SAFE with an EXIT trap installed before the park and disarmed after | ||
| // a successful restore. If ANY step in that region aborts the chain, the trap | ||
| // still restores tracked `.codeyam` (or re-inits), copies the two preserved | ||
| // infra files back, and runs `restart-server --force-in-container` before | ||
| // exiting non-zero — so a failed deep-clean self-recovers to a working | ||
| // (un-reset, but UP) VM instead of leaving it parked-and-down with a | ||
| // half-wiped `.codeyam`, and still reports the failure. The happy path is | ||
| // unchanged: the restore steps run, the trap is disarmed, and the caller's | ||
| // own trailing `restart-server` resurrects the parked server. | ||
| function codeyamDeepCleanSteps(E) { | ||
| // Restore primitives shared by the happy path and the trap recovery body. | ||
| const restoreCodeyam = `git checkout HEAD -- .codeyam 2>/dev/null || ${E} init`; | ||
| const restoreElj = | ||
| 'test -f /tmp/cy-elj.bak && mkdir -p .codeyam && cp /tmp/cy-elj.bak .codeyam/editor.local.json'; | ||
| const restoreSs = | ||
| 'test -f /tmp/cy-ss.bak && mkdir -p .codeyam && cp /tmp/cy-ss.bak .codeyam/server-state.json'; | ||
| // Recovery body: restore .codeyam + the two infra backups, then resurrect the | ||
| // parked server. Runs ONLY on the failure path (the happy path's own restore | ||
| // steps remove the backups; recovery here intentionally leaves them in place | ||
| // because on a mid-region abort those steps never ran). | ||
| const recover = | ||
| `${restoreCodeyam}; ( ${restoreElj}; true ); ( ${restoreSs}; true ); ` + | ||
| `${E} editor restart-server --force-in-container || true`; | ||
| return [ | ||
@@ -51,10 +82,46 @@ `( ${E} editor branch-queue release-mine --yes 2>/dev/null; true )`, | ||
| '( cp .codeyam/server-state.json /tmp/cy-ss.bak 2>/dev/null; true )', | ||
| // A brace group (NOT a subshell) so the function + trap persist into the | ||
| // current shell where the rest of the &&-chain runs. | ||
| `{ __cy_recover() { ${recover}; }; trap '__cy_recover; exit 1' EXIT; }`, | ||
| `(${E} editor stop-server --hold-for-restart --force-in-container || true)`, | ||
| 'rm -rf .codeyam', | ||
| `( git checkout HEAD -- .codeyam 2>/dev/null || ${E} init )`, | ||
| '( test -f /tmp/cy-elj.bak && mkdir -p .codeyam && cp /tmp/cy-elj.bak .codeyam/editor.local.json && rm -f /tmp/cy-elj.bak; true )', | ||
| '( test -f /tmp/cy-ss.bak && mkdir -p .codeyam && cp /tmp/cy-ss.bak .codeyam/server-state.json && rm -f /tmp/cy-ss.bak; true )', | ||
| `(${E} editor pty-broker stop || true)`, | ||
| '( for i in 1 2 3 4 5; do find .codeyam -mindepth 1 -delete 2>/dev/null; [ -z "$(ls -A .codeyam 2>/dev/null)" ] && break; sleep 0.2; done; true )', | ||
| `( ${restoreCodeyam} )`, | ||
| `( ${restoreElj} && rm -f /tmp/cy-elj.bak; true )`, | ||
| `( ${restoreSs} && rm -f /tmp/cy-ss.bak; true )`, | ||
| // Restore succeeded — disarm the failure-safe trap so normal completion | ||
| // (and the caller's trailing session-reset + restart) doesn't re-trigger it. | ||
| 'trap - EXIT', | ||
| ]; | ||
| } | ||
| // Belt-and-suspenders broker-ensure, appended after the trailing | ||
| // `restart-server` in every reset recipe. The PRIMARY fix lives in the | ||
| // editor server's `ensure_broker_running`: it now `rm -f`s the stale | ||
| // socket and retries the spawn, so the reexec'd `start` (which | ||
| // restart-server triggers) reliably respawns the broker with no | ||
| // down-window. This step is the safety net for an OLDER in-container | ||
| // binary whose `restart-server` predates that fix — it mirrors the | ||
| // vm-watchdog's recovery so a reset still ends broker-up regardless of | ||
| // which binary is installed. | ||
| // | ||
| // Shape: status-gated and idempotent. `pty-broker status` exits non-zero | ||
| // only when no broker is reachable; ONLY then does the `||` branch fire | ||
| // (`rm -f` the stale socket — mirroring the watchdog — then `nohup` a | ||
| // detached daemon). When a broker is already alive the status succeeds | ||
| // and the respawn branch never runs, so a healthy broker is NEVER killed. | ||
| // Wrapped in `( …; true )` so it can never break the reset's &&-chain | ||
| // (a down broker that fails to respawn must not abort the rest of the | ||
| // reset). The socket glob + `pty-broker daemon` invocation match the | ||
| // watchdog (`scripts/operator/vm-watchdog.sh`) and the per-project | ||
| // `$TMPDIR`/`/tmp` socket layout (`socket_path_for`). | ||
| function codeyamBrokerEnsureStep(E) { | ||
| return ( | ||
| `( ${E} editor pty-broker status >/dev/null 2>&1 || ` + | ||
| `( rm -f /tmp/codeyam-pty-broker-*.sock; ` + | ||
| `nohup ${E} editor pty-broker daemon </dev/null >/tmp/broker-spawn.log 2>&1 & disown ); ` + | ||
| 'true )' | ||
| ); | ||
| } | ||
| // Build the in-container bash for "Reset client" — hard-clean the CLIENT | ||
@@ -90,2 +157,5 @@ // project (/workspace) on ANY VM shape. The decisions encoded here: | ||
| `(${E} editor restart-server --force-in-container || true)`, | ||
| // Belt-and-suspenders: ensure a broker is live even on an older binary | ||
| // whose restart-server doesn't yet respawn it. Idempotent / status-gated. | ||
| codeyamBrokerEnsureStep(E), | ||
| 'echo ">> [5/5] re-seeding git credential cache"', | ||
@@ -144,5 +214,13 @@ `( ( ${gitReseedBody} ) || echo "WARN git cache re-seed failed" )`, | ||
| `(${E} editor restart-server --force-in-container || true)`, | ||
| // Belt-and-suspenders: ensure a broker is live even on an older binary | ||
| // whose restart-server doesn't yet respawn it. Idempotent / status-gated. | ||
| codeyamBrokerEnsureStep(E), | ||
| ].join(' && '); | ||
| } | ||
| module.exports = { codeyamDeepCleanSteps, resetClientScript, resetEditorStateScript }; | ||
| module.exports = { | ||
| codeyamDeepCleanSteps, | ||
| codeyamBrokerEnsureStep, | ||
| resetClientScript, | ||
| resetEditorStateScript, | ||
| }; |
+88
-10
@@ -61,8 +61,52 @@ "use strict"; | ||
| /** Phase-1 gate mirror of cloud.js::assertVariantProvisionable — only `dev` | ||
| * provisions today; staging/production are Phase 2. */ | ||
| /** Gate mirror of cloud.js::assertVariantProvisionable — which editor variants | ||
| * can be provisioned onto a cloud VM. All three variants now provision: `dev` | ||
| * builds the editor image from source, while `staging`/`production` run the | ||
| * published npm package (gcp-bootstrap pulls/builds Dockerfile.npm keyed off | ||
| * the variant). An unknown variant is excluded so a typo never reaches a | ||
| * launch. Pure; no I/O. */ | ||
| function isVariantProvisionable(variant) { | ||
| return variant === "dev"; | ||
| return EDITOR_VARIANTS.includes(variant); | ||
| } | ||
| /** Gate for the in-place BINARY-REBUILD operations (the dashboard's "Rebuild | ||
| * binary" / "Update & Reset Everything" actions, and reconfigure's trailing | ||
| * binary refresh). Historically distinct from `isVariantProvisionable` because a | ||
| * packaged variant's rebuild is an npm-reinstall (rebuildStrategy === | ||
| * "npm-reinstall") and that host-script path was not wired — so this stayed | ||
| * dev-only and packaged rebuild buttons failed loud. With the npm-reinstall | ||
| * branch now wired into `rebuildBinaryHostScript` (it rebuilds the Dockerfile.npm | ||
| * image at the variant's dist-tag instead of source-building Dockerfile), every | ||
| * provisionable variant is also rebuildable, so this now mirrors | ||
| * `isVariantProvisionable` — only an unknown variant is rejected. Pure; no I/O. */ | ||
| function isVariantRebuildable(variant) { | ||
| return isVariantProvisionable(variant); | ||
| } | ||
| /** Map an editor variant to the npm dist-tag its package was published under. | ||
| * CI publishes `codeyam.com` → "latest" (production) and `staging.codeyam.com` | ||
| * → "staging" (see .github/workflows/cicd.yml "Set release variant"). `dev` has | ||
| * no published package (it builds the image from source), so return null and let | ||
| * callers reject it. This is the single source of truth for the variant→dist-tag | ||
| * contract — the Dockerfile.npm build-arg, publish-editor-image.sh's `--from-npm` | ||
| * mode, and the future reconfigure path all derive from it so they can't drift. | ||
| * Pure; no I/O. */ | ||
| function variantNpmDistTag(variant) { | ||
| if (variant === "production") return "latest"; | ||
| if (variant === "staging") return "staging"; | ||
| return null; // dev / unknown — no packaged dist-tag | ||
| } | ||
| /** Map a packaged editor variant to the GHCR image-tag suffix the packaged | ||
| * image is pushed under (`<registry>/codeyam-editor-npm:<suffix>`). Lives beside | ||
| * `variantNpmDistTag` so the registry naming convention and the dist-tag mapping | ||
| * stay one reviewable unit; `scripts/publish-editor-image.sh --from-npm` asserts | ||
| * the literal string this returns. `dev` has no packaged image (built from | ||
| * source), so return null. Pure; no I/O. */ | ||
| function variantImageTag(variant) { | ||
| if (variant === "production") return "production-latest"; | ||
| if (variant === "staging") return "staging-latest"; | ||
| return null; // dev / unknown — no packaged image | ||
| } | ||
| /** Build the `cloud:up` flag array for a launch config + instance name. The | ||
@@ -262,2 +306,21 @@ * editor source is the canonical codeyam-editor repo (dev variant builds the | ||
| /** Sticky-merge a VM poll's `clientProject` against the last-known-good value | ||
| * so a transient null poll (timeout / partial probe) does not clobber a | ||
| * populated identity. The live `clientProject.branch` is the fleet | ||
| * classifier's first-choice branch source (`plan_actions` in | ||
| * fleet-roll-to-branch.sh); dropping it to null on a missed cycle would | ||
| * regress classification to the stale launch-history branch. Mirrors how the | ||
| * other live fields tolerate a missed poll: a populated `prev` survives a | ||
| * null/absent fresh poll, and a populated fresh poll always wins. Pure; no I/O. | ||
| * | ||
| * @param prev the previous `state.vms[n].clientProject` (null/undefined ok) | ||
| * @param info the fresh poll result object (null when the probe failed) | ||
| * @returns the clientProject to store: fresh value when present, else the | ||
| * last-known-good `prev`, else null. */ | ||
| function stickyClientProject(prev, info) { | ||
| const fresh = info && info.clientProject; | ||
| if (fresh) return fresh; | ||
| return prev || null; | ||
| } | ||
| /** Classify a VM's project KIND as `"self-host"` vs `"guest"` from the live | ||
@@ -333,7 +396,17 @@ * identity reported in `/api/session-info` (`clientProject.repo`), with | ||
| * bug). These paths are fixed constants, not user input — no injection surface, so | ||
| * `shq()` (which exists to neutralize user input) is the wrong tool. Pure; no I/O. */ | ||
| * `shq()` (which exists to neutralize user input) is the wrong tool. Pure; no I/O. | ||
| * | ||
| * The primary clone is kept only when it is actually USABLE as an editor source, | ||
| * not merely when a `.git` directory exists. A half-cloned / corrupt tree has a | ||
| * `.git` dir yet can't build — `git rev-parse` fails and there's no Dockerfile, so | ||
| * `docker build "$ESRC"` dies on `open Dockerfile: no such file or directory` (the | ||
| * improve39 VM-9 bug). The gate therefore requires a valid work tree AND a | ||
| * Dockerfile; otherwise it falls back to the canonical image clone | ||
| * `$HOME/codeyam-editor` (the authoritative source provisioning built the image | ||
| * from). This preserves the prefer-credentialed-clone behavior, but only when the | ||
| * credentialed clone actually works. */ | ||
| function editorSourceShellResolve(src) { | ||
| const primary = `ESRC="${src.path}"`; | ||
| if (!src.fallbackPath) return primary; | ||
| return `${primary}; [ -d "$ESRC/.git" ] || ESRC="${src.fallbackPath}"`; | ||
| return `${primary}; { git -C "$ESRC" rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ -f "$ESRC/Dockerfile" ]; } || ESRC="${src.fallbackPath}"`; | ||
| } | ||
@@ -349,7 +422,8 @@ | ||
| * `docker build` + container recreate. | ||
| * - "npm-reinstall" — a packaged variant (`staging`/`production`, Phase 2): | ||
| * the binary is the published editor npm package, so a | ||
| * rebuild is a reinstall, not a compile. | ||
| * Pure; no I/O. The dashboard reuses `isVariantProvisionable` to gate the | ||
| * not-yet-wired npm-reinstall path; the self-host axis is detected at runtime | ||
| * - "npm-reinstall" — a packaged variant (`staging`/`production`): the binary | ||
| * is the published editor npm package, so a rebuild is a | ||
| * reinstall, not a compile. | ||
| * Pure; no I/O. `rebuildBinaryHostScript` consumes this to pick its build line | ||
| * (the npm-reinstall branch rebuilds the Dockerfile.npm image at the variant's | ||
| * dist-tag); the self-host axis is detected at runtime | ||
| * on the VM (the `[ -f /workspace/Cargo.toml ]` signal is authoritative even | ||
@@ -529,2 +603,5 @@ * for VMs the dashboard has no launch-history record for). */ | ||
| isVariantProvisionable, | ||
| isVariantRebuildable, | ||
| variantNpmDistTag, | ||
| variantImageTag, | ||
| cloudUpArgs, | ||
@@ -540,2 +617,3 @@ classifyContainerWait, | ||
| resolveCardIdentity, | ||
| stickyClientProject, | ||
| classifyProjectKind, | ||
@@ -542,0 +620,0 @@ resolveCredsSource, |
@@ -13,2 +13,11 @@ "use strict"; | ||
| * legacy project. Anything else with no `editor.json` is greenfield. | ||
| * | ||
| * Rust-side behavior note (improve39): `start_preflight` no longer bails on an | ||
| * EMPTY greenfield folder — it auto-inits in place and boots the editor's wired | ||
| * scaffold flow. It still bails (exit 2) for an existing-source `none` folder | ||
| * (the `greenfieldGuidance` text below) and for legacy. This module is consumed | ||
| * by the launcher's bail/guidance path only; `classifyProject` does not itself | ||
| * decide empty-vs-non-empty (the launcher's `planPreflightAction` / | ||
| * `postLaunchInitBanner` in editor-dev.js own that split and intentionally | ||
| * auto-init all `none` projects as a local-dev convenience). | ||
| */ | ||
@@ -15,0 +24,0 @@ |
@@ -81,3 +81,6 @@ #!/usr/bin/env node | ||
| waitForStablePage, | ||
| createNetworkTracker, | ||
| waitForNetworkQuiet, | ||
| collectContentState, | ||
| performInteraction, | ||
| } = require("./scenario-playwright"); | ||
@@ -307,2 +310,7 @@ | ||
| const page = await context.newPage(); | ||
| // Attach the network tracker BEFORE navigation so every request (the document | ||
| // and every client-side data fetch) is counted. Used after DOM stability to | ||
| // wait out an in-flight fetch that would otherwise be screenshotted as a | ||
| // loading skeleton. | ||
| const networkTracker = createNetworkTracker(page); | ||
| await attachHttpMocks(page, httpMocks); | ||
@@ -366,2 +374,9 @@ | ||
| // DOM-stable does not mean done: a client-side data fetch can still be in | ||
| // flight (the loading skeleton cleared but its replacement content hasn't | ||
| // landed). Wait for the network to go quiet — bounded, so a streaming / | ||
| // long-poll endpoint that never idles caps out and captures anyway rather | ||
| // than hanging. | ||
| await waitForNetworkQuiet(networkTracker); | ||
| const rejectionMessages = await frame.evaluate( | ||
@@ -436,2 +451,12 @@ () => window.__codeyamUnhandledRejections || [], | ||
| // Drive the requested interaction (if any) against the settled frame, | ||
| // then re-settle, so `preview-interact` captures the RESULT of a click / | ||
| // fill / press (expanded accordion, open modal) without editing app | ||
| // source. A no-match target throws here and is caught below as a failed | ||
| // capture with the candidate-labels hint — never a silent blank shot. | ||
| if (config.interaction) { | ||
| await performInteraction(frame, config.interaction); | ||
| await waitForStablePage(page, frame, 5000, loadingMarkers); | ||
| } | ||
| if (outputPath && loaded) { | ||
@@ -438,0 +463,0 @@ fs.mkdirSync(path.dirname(outputPath), { recursive: true }); |
@@ -95,5 +95,6 @@ const fs = require("fs"); | ||
| // Returns `{ controlCount, frameworkAttached }` where `frameworkAttached` is | ||
| // `true` (runtime demonstrably attached), `false` (controls exist but no | ||
| // attachment signal — the dead-hydration case), or `null` (no detector for | ||
| // this framework → cannot judge). | ||
| // `true` (runtime demonstrably attached), `false` (framework-owned controls | ||
| // exist but no attachment signal — the dead-hydration case), or `null` (no | ||
| // detector for this framework, OR every interactive control is delegated to a | ||
| // terminal/canvas widget with no hydration marker → cannot judge). | ||
| async function collectHydrationState(frame, { framework } = {}) { | ||
@@ -103,8 +104,26 @@ return frame.evaluate((fw) => { | ||
| 'button, [role="button"], a[href], input:not([type="hidden"]), select, textarea, summary, [onclick]'; | ||
| // Roots of third-party terminal/canvas widgets that mount imperatively and | ||
| // wire their own (non-framework) event handlers — xterm's `.xterm`, any | ||
| // `<canvas>`, or an element a component explicitly flags terminal-backed. | ||
| // Interactive controls inside these (e.g. xterm's hidden helper | ||
| // `<textarea>`) never carry framework attachment keys even on a fully | ||
| // hydrated page, so judging hydration from them yields a false "not | ||
| // interactive" verdict. Stack assumption: `.xterm` is xterm-specific; the | ||
| // `<canvas>` and `[data-terminal-backed]` entries are framework-agnostic | ||
| // escape hatches any widget-embedding component can use. | ||
| const WIDGET_ROOT_SELECTOR = ".xterm, canvas, [data-terminal-backed]"; | ||
| const controls = Array.from(document.querySelectorAll(SELECTOR)); | ||
| const inWidget = (el) => | ||
| !!el && | ||
| typeof el.closest === "function" && | ||
| el.closest(WIDGET_ROOT_SELECTOR) != null; | ||
| // Framework-owned controls: those NOT delegated to a terminal/canvas | ||
| // widget. Only these can prove or disprove that the framework hydrated. | ||
| const frameworkControls = controls.filter((el) => !inWidget(el)); | ||
| // Per-framework attachment detectors. Each returns true when the framework | ||
| // has demonstrably attached its client runtime to a live interactive node. | ||
| // The presence of a detector for `fw` is itself the signal that we CAN | ||
| // judge this framework; the default (no detector) means "cannot determine". | ||
| // has demonstrably attached its client runtime to a live node. The | ||
| // presence of a detector for `fw` is itself the signal that we CAN judge | ||
| // this framework; the default (no detector) means "cannot determine". | ||
| const detectors = { | ||
@@ -133,3 +152,30 @@ react: (els) => | ||
| const detector = detectors[fw]; | ||
| const frameworkAttached = detector ? detector(controls) : null; | ||
| // Terminal/canvas scenarios prove hydration with an explicit, framework- | ||
| // rendered `data-codeyam-hydrated` marker. It counts only when the | ||
| // framework actually attached to it — a marker that exists in static HTML | ||
| // but never hydrated fails the same detector and does not count. | ||
| const markerEl = document.querySelector("[data-codeyam-hydrated]"); | ||
| const markerAttached = detector | ||
| ? detector(markerEl ? [markerEl] : []) | ||
| : false; | ||
| let frameworkAttached; | ||
| if (!detector) { | ||
| // No detector for this framework — cannot judge (conservative pass). | ||
| frameworkAttached = null; | ||
| } else if (detector(frameworkControls) || markerAttached) { | ||
| // A genuinely framework-owned control attached, or an explicit hydration | ||
| // marker proves the framework ran — definitively hydrated. | ||
| frameworkAttached = true; | ||
| } else if (frameworkControls.length === 0) { | ||
| // Controls exist but every one lives inside a terminal/canvas widget and | ||
| // there is no marker — we cannot prove the page is dead, so never flag. | ||
| frameworkAttached = null; | ||
| } else { | ||
| // Framework-owned controls rendered but none attached — the dead-island | ||
| // signal the gate exists to catch. | ||
| frameworkAttached = false; | ||
| } | ||
| return { controlCount: controls.length, frameworkAttached }; | ||
@@ -136,0 +182,0 @@ }, framework || null); |
@@ -222,2 +222,69 @@ const { | ||
| // Track in-flight network requests on a Playwright page so a capture can wait | ||
| // for client-side data fetches to settle before screenshotting. The | ||
| // resource-timing API (`performance.getEntriesByType("resource")`) only records | ||
| // COMPLETED requests, so it cannot see a fetch that is still in flight — the | ||
| // exact window where a client-fetch page shows a loading skeleton. We count | ||
| // request starts against finishes/failures instead. Returns a live view: | ||
| // `inFlight()` is the current outstanding count and `lastActivityMs()` is the | ||
| // timestamp of the most recent request start OR completion (0 when the page has | ||
| // made no requests since the tracker attached). Attach BEFORE navigation so | ||
| // every request is counted. Stack-agnostic — it observes raw HTTP activity, not | ||
| // any framework's fetch wrapper. | ||
| function createNetworkTracker(page) { | ||
| let inFlight = 0; | ||
| let lastActivityMs = 0; | ||
| const bump = () => { | ||
| lastActivityMs = Date.now(); | ||
| }; | ||
| if (page && typeof page.on === "function") { | ||
| page.on("request", () => { | ||
| inFlight += 1; | ||
| bump(); | ||
| }); | ||
| const settle = () => { | ||
| inFlight = Math.max(0, inFlight - 1); | ||
| bump(); | ||
| }; | ||
| page.on("requestfinished", settle); | ||
| page.on("requestfailed", settle); | ||
| } | ||
| return { | ||
| inFlight: () => inFlight, | ||
| lastActivityMs: () => lastActivityMs, | ||
| }; | ||
| } | ||
| // Bounded network-quiet wait: after the DOM is stable a client-side data fetch | ||
| // can still be in flight — the loading skeleton is gone but the fetched rows | ||
| // haven't replaced it yet, so a screenshot here catches the in-between frame. | ||
| // Wait until no request has been outstanding for `quietWindowMs`, hard-capped at | ||
| // `overallTimeoutMs`. Two properties matter: | ||
| // - A page that made NO requests (lastActivityMs stays 0) is already quiet and | ||
| // returns on the first poll, so server-rendered captures incur no extra wait. | ||
| // - A streaming / long-poll endpoint that never goes idle hits the cap and the | ||
| // caller captures anyway — the wait can never hang the capture. | ||
| async function waitForNetworkQuiet( | ||
| tracker, | ||
| { quietWindowMs = 500, overallTimeoutMs = 5000, pollIntervalMs = 100 } = {}, | ||
| ) { | ||
| const started = Date.now(); | ||
| while (Date.now() - started < overallTimeoutMs) { | ||
| const idleForMs = Date.now() - tracker.lastActivityMs(); | ||
| if (tracker.inFlight() === 0 && idleForMs >= quietWindowMs) { | ||
| const elapsedMs = Date.now() - started; | ||
| logCaptureTiming("network-quiet", { outcome: "quiet", elapsedMs }); | ||
| return { quiet: true, elapsedMs }; | ||
| } | ||
| await new Promise((r) => setTimeout(r, pollIntervalMs)); | ||
| } | ||
| const elapsedMs = Date.now() - started; | ||
| logCaptureTiming("network-quiet", { | ||
| outcome: "capped", | ||
| elapsedMs, | ||
| inFlight: tracker.inFlight(), | ||
| }); | ||
| return { quiet: false, elapsedMs }; | ||
| } | ||
| // `loadingMarkers` are the project's app-specific loading strings (from | ||
@@ -237,6 +304,20 @@ // stack.json `capture.loadingMarkers`); they extend the codeyam-harness | ||
| const pageState = await target.evaluate(() => ({ | ||
| bodyText: document.body?.innerText ?? "", | ||
| html: document.body?.innerHTML ?? "", | ||
| })); | ||
| const pageState = await target.evaluate(() => { | ||
| const getById = document.getElementById; | ||
| const root = | ||
| typeof getById === "function" ? document.getElementById("root") : null; | ||
| return { | ||
| bodyText: document.body?.innerText ?? "", | ||
| html: document.body?.innerHTML ?? "", | ||
| // Whether the SPA mount point exists AND has painted anything. An | ||
| // existing-but-empty `<div id="root">` is the pre-paint window of a | ||
| // slow-first-paint scenario (e.g. a live-session app that spends a few | ||
| // seconds connecting before the gate UI mounts). `rootExists` lets us | ||
| // distinguish that from a mid-redirect/teardown `null` body, where | ||
| // there is no root to wait on and a stable-empty page is legitimately | ||
| // settled. | ||
| rootExists: !!root, | ||
| rootChildCount: root ? root.childElementCount : 0, | ||
| }; | ||
| }); | ||
@@ -246,3 +327,15 @@ lastHadLoadingMarkers = hasLoadingMarkers(pageState.bodyText, loadingMarkers); | ||
| if (!lastHadLoadingMarkers && !lastHtmlChanged) { | ||
| // A mounted-but-unpainted root is "still loading", not "settled": the HTML | ||
| // can sit byte-stable for a second or two while the SPA boots, which would | ||
| // otherwise satisfy the stability check and capture a blank frame before | ||
| // first paint. Treat an existing root with zero children AND no body text | ||
| // as not-ready so the loop keeps polling until the app actually paints (or | ||
| // the overall timeout fires, by which point real content is present). A | ||
| // `null` body (rootExists=false) is unaffected — it stays trivially stable. | ||
| const rootUnpainted = | ||
| pageState.rootExists && | ||
| pageState.rootChildCount === 0 && | ||
| (pageState.bodyText ?? "").trim().length === 0; | ||
| if (!lastHadLoadingMarkers && !lastHtmlChanged && !rootUnpainted) { | ||
| stableCount += 1; | ||
@@ -345,2 +438,84 @@ if (stableCount >= 2) { | ||
| // Collect up to 20 distinct visible labels of interactive elements on the | ||
| // page — buttons, links, role=button, form controls, <summary>, and anything | ||
| // with an onclick. Used to build an ACTIONABLE error when an interaction's | ||
| // target matches nothing: the agent reliably knows a label it rendered, so | ||
| // listing the real candidates turns a silent blank capture into a "did you | ||
| // mean one of these?" hint. Pure read (no clicks); falls back to value / | ||
| // aria-label / placeholder when an element has no text. | ||
| async function collectInteractiveLabels(frame) { | ||
| return frame.evaluate(() => { | ||
| const selector = | ||
| "button, a[href], [role=button], input, select, textarea, summary, [onclick]"; | ||
| const nodes = Array.from(document.querySelectorAll(selector)); | ||
| const labels = nodes | ||
| .map((node) => { | ||
| const text = | ||
| (node.innerText || node.textContent || "").trim() || | ||
| (typeof node.value === "string" ? node.value.trim() : "") || | ||
| (node.getAttribute && node.getAttribute("aria-label")) || | ||
| (node.getAttribute && node.getAttribute("placeholder")) || | ||
| ""; | ||
| return String(text).trim(); | ||
| }) | ||
| .filter((label) => label.length > 0); | ||
| return Array.from(new Set(labels)).slice(0, 20); | ||
| }); | ||
| } | ||
| // Drive a single user-style interaction against the settled frame before the | ||
| // screenshot, so an interactive state (expanded accordion, open modal, filled | ||
| // field) can be captured without editing app source. | ||
| // | ||
| // The target is matched by visible `text` (preferred — the agent reliably | ||
| // knows the label it rendered) or a CSS `selector`. `action` is click / fill / | ||
| // press; `value` carries the text for `fill` or the key for `press` (e.g. | ||
| // `Enter`). On a no-match target this THROWS with the list of candidate | ||
| // interactive labels — the capture script's outer catch turns that into a | ||
| // failed capture with an actionable message, never a silent blank screenshot. | ||
| async function performInteraction(frame, interaction, { timeoutMs = 5000 } = {}) { | ||
| const { action, selector, text, value } = interaction || {}; | ||
| let locator; | ||
| let targetDesc; | ||
| if (typeof text === "string" && text.length > 0) { | ||
| locator = frame.getByText(text, { exact: false }).first(); | ||
| targetDesc = `text "${text}"`; | ||
| } else if (typeof selector === "string" && selector.length > 0) { | ||
| locator = frame.locator(selector).first(); | ||
| targetDesc = `selector "${selector}"`; | ||
| } else { | ||
| throw new Error( | ||
| "preview-interact: interaction requires a `text` or `selector` target", | ||
| ); | ||
| } | ||
| const matchCount = await locator.count(); | ||
| if (matchCount === 0) { | ||
| const candidates = await collectInteractiveLabels(frame); | ||
| const candidateList = | ||
| candidates.length > 0 ? candidates.join(", ") : "(none found on page)"; | ||
| throw new Error( | ||
| `preview-interact: no element matched ${targetDesc}. ` + | ||
| `Candidate interactive labels: ${candidateList}`, | ||
| ); | ||
| } | ||
| switch (action) { | ||
| case "click": | ||
| await locator.click({ timeout: timeoutMs }); | ||
| break; | ||
| case "fill": | ||
| await locator.fill(value ?? "", { timeout: timeoutMs }); | ||
| break; | ||
| case "press": | ||
| await locator.press(value || "Enter", { timeout: timeoutMs }); | ||
| break; | ||
| default: | ||
| throw new Error( | ||
| `preview-interact: unknown action "${action}" (expected click | fill | press)`, | ||
| ); | ||
| } | ||
| } | ||
| module.exports = { | ||
@@ -356,5 +531,9 @@ logCaptureTiming, | ||
| waitForAnimationsSettled, | ||
| createNetworkTracker, | ||
| waitForNetworkQuiet, | ||
| waitForStablePage, | ||
| loadScenarioInIframe, | ||
| loadScenarioTopLevel, | ||
| collectInteractiveLabels, | ||
| performInteraction, | ||
| }; |
+100
-46
@@ -11,10 +11,16 @@ const { execSync, spawn, spawnSync } = require("child_process"); | ||
| * Map (process.platform, process.arch) → the platform sub-package that | ||
| * carries the matching native binary. Kept as a small data table so adding | ||
| * a new platform (e.g. linux/arm64) is one entry, not a new branch. | ||
| * carries the matching native binaries. Each sub-package ships TWO | ||
| * binaries side-by-side in its `bin/` dir: the `codeyam-editor` monolith | ||
| * and the dedicated `codeyam-editor-pty-broker` daemon (its own | ||
| * executable file / inode, for a stable honest process name). They live | ||
| * in the same dir so the broker auto-spawn's sibling-of-current-exe | ||
| * resolution finds the broker next to the monolith with no copying. | ||
| * Kept as a small data table so adding a new platform (e.g. linux/arm64) | ||
| * is one entry, not a new branch. | ||
| */ | ||
| const PLATFORM_PACKAGES = { | ||
| "darwin-arm64": { pkg: "@codeyam-editor/codeyam-editor-darwin-arm64", binary: "codeyam-editor" }, | ||
| "darwin-x64": { pkg: "@codeyam-editor/codeyam-editor-darwin-x64", binary: "codeyam-editor" }, | ||
| "linux-x64": { pkg: "@codeyam-editor/codeyam-editor-linux-x64", binary: "codeyam-editor" }, | ||
| "win32-x64": { pkg: "@codeyam-editor/codeyam-editor-win32-x64", binary: "codeyam-editor.exe" }, | ||
| "darwin-arm64": { pkg: "@codeyam-editor/codeyam-editor-darwin-arm64", binary: "codeyam-editor", broker: "codeyam-editor-pty-broker" }, | ||
| "darwin-x64": { pkg: "@codeyam-editor/codeyam-editor-darwin-x64", binary: "codeyam-editor", broker: "codeyam-editor-pty-broker" }, | ||
| "linux-x64": { pkg: "@codeyam-editor/codeyam-editor-linux-x64", binary: "codeyam-editor", broker: "codeyam-editor-pty-broker" }, | ||
| "win32-x64": { pkg: "@codeyam-editor/codeyam-editor-win32-x64", binary: "codeyam-editor.exe", broker: "codeyam-editor-pty-broker.exe" }, | ||
| }; | ||
@@ -31,2 +37,10 @@ | ||
| /** | ||
| * Platform-correct executable filename for the dedicated broker binary. | ||
| * Mirrors {@link binaryName} for the second shipped binary. | ||
| */ | ||
| function brokerBinaryName(platform = process.platform) { | ||
| return platform === "win32" ? "codeyam-editor-pty-broker.exe" : "codeyam-editor-pty-broker"; | ||
| } | ||
| /** Look up the platform sub-package metadata for a given platform/arch pair. */ | ||
@@ -67,6 +81,21 @@ function platformPackageInfo(platform = process.platform, arch = process.arch) { | ||
| const name = binaryName(); | ||
| const devCandidates = [ | ||
| path.join(rootDir, "target", "debug", name), | ||
| path.join(rootDir, "target", "release", name), | ||
| ]; | ||
| // Target directories to search, in precedence order. A custom | ||
| // CARGO_TARGET_DIR (cloud VMs set CARGO_TARGET_DIR=/codeyam-build-target) | ||
| // wins over the default ./target — cargo builds ONLY into the configured | ||
| // dir, so ./target may be stale or absent. Honoring it here keeps | ||
| // `ensureBinary` from building successfully and then failing to locate the | ||
| // result. CARGO_TARGET_DIR may be absolute or relative to the workspace | ||
| // root; path.resolve handles both (an absolute value ignores rootDir). | ||
| const targetDirs = []; | ||
| const customTarget = process.env.CARGO_TARGET_DIR; | ||
| if (customTarget) { | ||
| targetDirs.push(path.resolve(rootDir, customTarget)); | ||
| } | ||
| targetDirs.push(path.join(rootDir, "target")); | ||
| const devCandidates = []; | ||
| for (const dir of targetDirs) { | ||
| devCandidates.push(path.join(dir, "debug", name)); | ||
| devCandidates.push(path.join(dir, "release", name)); | ||
| } | ||
| for (const candidate of devCandidates) { | ||
@@ -139,47 +168,70 @@ if (fs.existsSync(candidate)) return candidate; | ||
| } | ||
| verifyBrokerBinaryPresent(binary); | ||
| return binary; | ||
| } | ||
| /** Resolve the UI dist directory for the binary to serve. */ | ||
| function uiDistDir() { | ||
| return path.join(rootDir, "ui", "dist"); | ||
| } | ||
| /** | ||
| * True when `cwd` IS the editor repo root (or is nested under it), so the | ||
| * wrapper is being used to develop the editor itself rather than against an | ||
| * external client project. Compares realpath-resolved paths so a symlinked | ||
| * checkout (`~/work/foo` → `/Volumes/dev/foo`) still matches — the same | ||
| * convention `crates/control-api/src/state.rs:203` documents for the | ||
| * server-side identity probe. | ||
| * | ||
| * Pure and synchronous (uses `fs.realpathSync`). On a realpath failure (an | ||
| * unreadable path, race with a moved file) the un-resolved value is used as | ||
| * a fallback — the same defensive shape `canonicalisePath` uses, so the | ||
| * wrapper never bails on a transient fs hiccup. | ||
| * | ||
| * Gate for the dev wrapper's passthrough `cargo build`: when the target | ||
| * project is the editor itself, rebuilding makes sense (the developer is | ||
| * iterating on editor source). When it is any other project, the rebuild | ||
| * does nothing of value and ripples into the stale-binary watcher's | ||
| * SIGUSR1 re-exec, resetting the user's session for no reason. The | ||
| * `CODEYAM_DEV_REBUILD=1` env preserves the one legitimate "editing the | ||
| * editor against a client project" workflow. | ||
| * Resolve the dedicated broker binary path. Mirrors {@link findBinary}: | ||
| * the published platform sub-package ships it next to the monolith, and | ||
| * a dev `cargo build` produces it under target/debug|release. Returns | ||
| * null if it can't be located — the Rust broker auto-spawn then falls | ||
| * back to the monolith `editor pty-broker daemon` shim, so a missing | ||
| * broker binary degrades gracefully rather than breaking the editor. | ||
| */ | ||
| function isSelfHostTarget(cwd, repoRoot) { | ||
| const safeReal = (p) => { | ||
| function findBrokerBinary() { | ||
| const info = platformPackageInfo(); | ||
| if (info && info.broker) { | ||
| try { | ||
| return fs.realpathSync(p); | ||
| } catch { | ||
| return p; | ||
| const resolved = require.resolve(`${info.pkg}/bin/${info.broker}`, { | ||
| paths: [rootDir], | ||
| }); | ||
| if (fs.existsSync(resolved)) return resolved; | ||
| } catch (e) { | ||
| if (e.code !== "MODULE_NOT_FOUND") throw e; | ||
| } | ||
| }; | ||
| const cwdReal = safeReal(cwd); | ||
| const repoReal = safeReal(repoRoot); | ||
| if (cwdReal === repoReal) return true; | ||
| const sep = path.sep; | ||
| return cwdReal.startsWith(repoReal.endsWith(sep) ? repoReal : repoReal + sep); | ||
| } | ||
| const name = brokerBinaryName(); | ||
| const targetDirs = []; | ||
| const customTarget = process.env.CARGO_TARGET_DIR; | ||
| if (customTarget) { | ||
| targetDirs.push(path.resolve(rootDir, customTarget)); | ||
| } | ||
| targetDirs.push(path.join(rootDir, "target")); | ||
| for (const dir of targetDirs) { | ||
| for (const profile of ["debug", "release"]) { | ||
| const candidate = path.join(dir, profile, name); | ||
| if (fs.existsSync(candidate)) return candidate; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * Best-effort check that the dedicated broker binary sits next to the | ||
| * monolith (the layout the broker auto-spawn's sibling resolution | ||
| * expects). Logs a one-line note when it's missing — never throws or | ||
| * exits, because the Rust side degrades to the monolith shim. `monolith` | ||
| * is the resolved monolith binary path; the broker is expected as its | ||
| * sibling. | ||
| */ | ||
| function verifyBrokerBinaryPresent(monolith) { | ||
| const sibling = path.join(path.dirname(monolith), brokerBinaryName()); | ||
| if (fs.existsSync(sibling)) return; | ||
| const located = findBrokerBinary(); | ||
| if (located) return; | ||
| console.error( | ||
| "Note: dedicated broker binary (codeyam-editor-pty-broker) not found next to " + | ||
| `${monolith}. The PTY broker will fall back to the monolith \`editor pty-broker daemon\` ` + | ||
| "shim. Reinstall the platform sub-package, or run `cargo build -p codeyam-pty-broker`." | ||
| ); | ||
| } | ||
| /** Resolve the UI dist directory for the binary to serve. */ | ||
| function uiDistDir() { | ||
| return path.join(rootDir, "ui", "dist"); | ||
| } | ||
| /** | ||
| * Loud banner printed when `cargo build` failed and a wrapper is about to fall | ||
@@ -741,7 +793,9 @@ * back to a stale binary. Shared by both wrappers (editor.js server boot, | ||
| binaryName, | ||
| brokerBinaryName, | ||
| platformPackageInfo, | ||
| PLATFORM_PACKAGES, | ||
| findBinary, | ||
| findBrokerBinary, | ||
| verifyBrokerBinaryPresent, | ||
| ensureBinary, | ||
| isSelfHostTarget, | ||
| staleBinaryBanner, | ||
@@ -748,0 +802,0 @@ enforceFreshBinary, |
+5
-5
| { | ||
| "name": "@codeyam-editor/codeyam-editor", | ||
| "version": "0.1.2", | ||
| "version": "0.1.3", | ||
| "description": "Language-agnostic managed execution sandbox for scenario-driven development", | ||
@@ -12,6 +12,6 @@ "bin": { | ||
| "optionalDependencies": { | ||
| "@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.2", | ||
| "@codeyam-editor/codeyam-editor-darwin-x64": "0.1.2", | ||
| "@codeyam-editor/codeyam-editor-linux-x64": "0.1.2", | ||
| "@codeyam-editor/codeyam-editor-win32-x64": "0.1.2" | ||
| "@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.3", | ||
| "@codeyam-editor/codeyam-editor-darwin-x64": "0.1.3", | ||
| "@codeyam-editor/codeyam-editor-linux-x64": "0.1.3", | ||
| "@codeyam-editor/codeyam-editor-win32-x64": "0.1.3" | ||
| }, | ||
@@ -18,0 +18,0 @@ "keywords": [ |
@@ -8,6 +8,6 @@ <!DOCTYPE html> | ||
| <link rel="icon" type="image/x-icon" href="/favicon.ico" /> | ||
| <script type="module" crossorigin src="/assets/index-BUYNFqKK.js"></script> | ||
| <script type="module" crossorigin src="/assets/index-KDFAgt2e.js"></script> | ||
| <link rel="modulepreload" crossorigin href="/assets/react-CSS0HapR.js"> | ||
| <link rel="modulepreload" crossorigin href="/assets/markdown-v0F0UEt3.js"> | ||
| <link rel="stylesheet" crossorigin href="/assets/index-DVr5rY2d.css"> | ||
| <link rel="stylesheet" crossorigin href="/assets/index-Bih6HJ_u.css"> | ||
| </head> | ||
@@ -14,0 +14,0 @@ <body> |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
| import{j as c}from"./markdown-v0F0UEt3.js";import{b as o}from"./react-CSS0HapR.js";import{S as m}from"./ScenarioDataPanel-zJSXtXL4.js";import"./useEvents-DggWrtHe.js";import"./xterm--24IGk-x.js";import"./index-BUYNFqKK.js";function S({slug:t}){const[r,i]=o.useState(void 0);return o.useEffect(()=>{let n=!1;return fetch(`/api/scenarios/${encodeURIComponent(t)}`).then(e=>e.ok?e.json():null).then(e=>{if(n||!e)return;const a=typeof e=="object"&&e!==null&&"name"in e?String(e.name):void 0;i(a),a&&(document.title=`${a} · data`)}).catch(()=>{}),()=>{n=!0}},[t]),c.jsx(m,{slug:t,scenarioName:r,variant:"fullPage"})}export{S as ScenarioDataPanelFullPage}; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances in 1 package
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
1845994
5.4%54
14.89%13734
13.08%50
4.17%146
1.39%