@codeyam-editor/codeyam-editor 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm/cloud.js +779 -116
- package/npm/container.js +11 -0
- package/npm/disk-pressure.js +89 -0
- package/npm/editor-dev.js +144 -52
- package/npm/editor.js +47 -17
- package/npm/fleet-branch.js +49 -0
- package/npm/fleet-broker-death.js +44 -0
- package/npm/fleet-broker-debounce.js +63 -0
- package/npm/fleet-broker-status.js +45 -0
- package/npm/fleet-build-data.js +81 -0
- package/npm/fleet-cleanup.js +216 -16
- package/npm/fleet-effect-status.js +179 -0
- package/npm/fleet-grouping.js +56 -0
- package/npm/fleet-job-badge.js +83 -0
- package/npm/fleet-memory.js +35 -5
- package/npm/fleet-plans.js +96 -0
- package/npm/fleet-reconcile.js +58 -1
- package/npm/fleet-reset.js +94 -16
- package/npm/launch-config.js +197 -14
- package/npm/preflight-guidance.js +9 -0
- package/npm/scenario-check.js +106 -1
- package/npm/scenario-handlers.js +16 -0
- package/npm/scenario-interactivity.js +53 -7
- package/npm/scenario-playwright.js +184 -5
- package/npm/utils.js +238 -53
- package/package.json +5 -5
- package/ui/dist/assets/EditorShell-tMMdarZD.js +187 -0
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +5 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-C0YHQB-T.js → ScenarioDataPanelFullPage-5G6KuZgw.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +1 -0
- package/ui/dist/assets/index-BGqairFV.css +1 -0
- package/ui/dist/assets/{index-BUYNFqKK.js → index-BuK0lizt.js} +9 -9
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-DWaeBxgj.js +0 -186
- package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +0 -1
- package/ui/dist/assets/index-DVr5rY2d.css +0 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Group an already-sorted/filtered VM list by client project for the Fleet
|
|
2
|
+
// Dashboard's grouped layout.
|
|
3
|
+
//
|
|
4
|
+
// Returns an ORDERED array of { project, vms } groups: real project groups
|
|
5
|
+
// first, sorted case-insensitively by name (locale compare); the
|
|
6
|
+
// unknown/starting-up bucket (project === null) pinned LAST. Within each group
|
|
7
|
+
// the incoming order of `vms` is preserved untouched, so the caller's active
|
|
8
|
+
// Sort mode continues to drive within-group order — grouping never re-sorts the
|
|
9
|
+
// cards, it only partitions them.
|
|
10
|
+
//
|
|
11
|
+
// `projectKey(v)` resolves the group key for a VM and defaults to the same
|
|
12
|
+
// identity `card()` renders in the dashboard:
|
|
13
|
+
// (v.cardIdentity && v.cardIdentity.project) || v.project || null
|
|
14
|
+
// It is injectable so unit tests can drive grouping without constructing full
|
|
15
|
+
// card payloads. A null/empty key routes the VM into the trailing unknown
|
|
16
|
+
// bucket so VMs mid-provision (blank identity) never silently disappear.
|
|
17
|
+
//
|
|
18
|
+
// Pure; no I/O.
|
|
19
|
+
|
|
20
|
+
function defaultProjectKey(v) {
|
|
21
|
+
return (v && v.cardIdentity && v.cardIdentity.project) || (v && v.project) || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function groupVmsByProject(vms, projectKey = defaultProjectKey) {
|
|
25
|
+
// First pass: stable bucketing. `Map` preserves insertion order, so the
|
|
26
|
+
// first VM of each project fixes that group's relative position before we
|
|
27
|
+
// re-order — but we re-sort named groups below regardless, so insertion
|
|
28
|
+
// order only matters for tie-stability within equal-name groups (none here).
|
|
29
|
+
const named = new Map();
|
|
30
|
+
let unknown = null;
|
|
31
|
+
|
|
32
|
+
for (const v of vms) {
|
|
33
|
+
const raw = projectKey(v);
|
|
34
|
+
const key = raw == null || raw === "" ? null : raw;
|
|
35
|
+
if (key === null) {
|
|
36
|
+
if (!unknown) unknown = { project: null, vms: [] };
|
|
37
|
+
unknown.vms.push(v);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
let group = named.get(key);
|
|
41
|
+
if (!group) {
|
|
42
|
+
group = { project: key, vms: [] };
|
|
43
|
+
named.set(key, group);
|
|
44
|
+
}
|
|
45
|
+
group.vms.push(v);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const groups = Array.from(named.values());
|
|
49
|
+
groups.sort((a, b) =>
|
|
50
|
+
a.project.localeCompare(b.project, undefined, { sensitivity: "base" })
|
|
51
|
+
);
|
|
52
|
+
if (unknown) groups.push(unknown);
|
|
53
|
+
return groups;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { groupVmsByProject, defaultProjectKey };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// npm/fleet-job-badge.js — pure verdict for the fleet dashboard's failed-job
|
|
4
|
+
// badge reconciliation (improve39). A VM's last terminal-error heavy job
|
|
5
|
+
// (add/error, reset-all error, reset-editor error, …) used to stick on the
|
|
6
|
+
// dashboard card indefinitely — even after the VM was recovered and is healthy
|
|
7
|
+
// again — because the card renders the last job's error with no check against
|
|
8
|
+
// the VM's live state, so a recovered VM keeps screaming a red error and
|
|
9
|
+
// misleads the operator. This decides whether such a badge should be RECONCILED
|
|
10
|
+
// (presented as "recovered" rather than a live error) because the VM is
|
|
11
|
+
// demonstrably fine now. The live signals are gathered in server.js (the
|
|
12
|
+
// untestable server shell); the verdict lives here so the cases are unit-tested
|
|
13
|
+
// without a live VM.
|
|
14
|
+
|
|
15
|
+
// Should the VM's last terminal-error job badge be reconciled against live
|
|
16
|
+
// health — i.e. presented as "recovered" rather than a live red error?
|
|
17
|
+
//
|
|
18
|
+
// job the carried job record ({ state, action, ... }) or null
|
|
19
|
+
// reachable the VM's editor answered this poll (true = healthy / responding)
|
|
20
|
+
// drifted a project-dir-drift signal is present (reachable-but-unwired /
|
|
21
|
+
// half-provisioned add — genuinely needs-recovery, owned by the
|
|
22
|
+
// companion reconcile-inflight-jobs plan; never falsely cleared here)
|
|
23
|
+
// offBranch the VM's live client branch differs from its target branch
|
|
24
|
+
// degraded the job completed but a wiring sub-step is flagged degraded
|
|
25
|
+
//
|
|
26
|
+
// Returns true only when the job is a terminal error AND the VM is demonstrably
|
|
27
|
+
// fine (reachable, not drifted, on-branch, not degraded). An unreachable /
|
|
28
|
+
// drifted / off-branch VM keeps its error surfaced — a real problem must stay
|
|
29
|
+
// visible.
|
|
30
|
+
function shouldReconcileFailedBadge({ job, reachable, drifted, offBranch, degraded } = {}) {
|
|
31
|
+
if (!job || job.state !== 'error') return false;
|
|
32
|
+
if (!reachable) return false;
|
|
33
|
+
if (drifted) return false;
|
|
34
|
+
if (offBranch) return false;
|
|
35
|
+
if (degraded) return false;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Given a reconcile verdict (from shouldReconcileFailedBadge) and whether the VM
|
|
40
|
+
// is now idle, decide whether the recovered badge should be FULLY CLEARED — the
|
|
41
|
+
// job entry dropped from state.jobs so the card goes clean — rather than merely
|
|
42
|
+
// muted as a "recovered (was: …)" note (improve41).
|
|
43
|
+
//
|
|
44
|
+
// badgeReconciled the shouldReconcileFailedBadge verdict for this VM
|
|
45
|
+
// idle the VM answered this poll, is not running a build session,
|
|
46
|
+
// and its broker is not actively serving an agent
|
|
47
|
+
//
|
|
48
|
+
// A recovered VM that is also idle has demonstrably finished recovering, so the
|
|
49
|
+
// stale error is dropped entirely. A recovered-but-not-yet-idle VM (mid-build,
|
|
50
|
+
// or a poll hasn't confirmed idle) keeps the muted note so the prior log stays
|
|
51
|
+
// visible during that transient window. Never clears a badge that wasn't first
|
|
52
|
+
// judged recovered — an unreachable / drifted / off-branch error stays surfaced.
|
|
53
|
+
function shouldClearReconciledBadge({ badgeReconciled, idle } = {}) {
|
|
54
|
+
return !!badgeReconciled && !!idle;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Pure guard for the operator "Clear badge" action (improve41). Decides whether
|
|
58
|
+
// VM-n's persisted job entry may be dropped after an out-of-band recovery,
|
|
59
|
+
// independent of the server's state / fs I/O so the cases are unit-tested
|
|
60
|
+
// without a live dashboard. The shell (server.js) supplies the live signals and
|
|
61
|
+
// turns the verdict into the {ok,msg} response + the actual delete + snapshot.
|
|
62
|
+
//
|
|
63
|
+
// rostered the VM number is in the live roster
|
|
64
|
+
// job the carried job record ({ state, ... }) or null/undefined
|
|
65
|
+
//
|
|
66
|
+
// Only a TERMINAL job (error/done) on a rostered VM may be cleared, OR a terminal
|
|
67
|
+
// failed add (add/error) on a non-rostered VM (which deletes the card + add-N sidecar):
|
|
68
|
+
// any other job state / non-rostered combination is not clearable.
|
|
69
|
+
// Returns { ok, reason, cleanup } — reason is a stable code the shell maps to an
|
|
70
|
+
// operator message, and cleanup is the cleanup action to perform ('add-failed' vs 'badge-only').
|
|
71
|
+
function canClearBadge({ rostered, job } = {}) {
|
|
72
|
+
if (!rostered) {
|
|
73
|
+
if (job && job.action === 'add' && job.state === 'error') {
|
|
74
|
+
return { ok: true, reason: null, cleanup: 'add-failed' };
|
|
75
|
+
}
|
|
76
|
+
return { ok: false, reason: 'not-rostered' };
|
|
77
|
+
}
|
|
78
|
+
if (!job) return { ok: false, reason: 'no-badge' };
|
|
79
|
+
if (job.state !== 'error' && job.state !== 'done') return { ok: false, reason: 'job-active' };
|
|
80
|
+
return { ok: true, reason: null, cleanup: 'badge-only' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { shouldReconcileFailedBadge, shouldClearReconciledBadge, canClearBadge };
|
package/npm/fleet-memory.js
CHANGED
|
@@ -74,17 +74,30 @@ function decodeStateSnapshot(buf) {
|
|
|
74
74
|
* Returns null when the snapshot is missing, malformed, or older than
|
|
75
75
|
* maxAgeMs (stale snapshots are worse than no snapshot — pollVMs will
|
|
76
76
|
* repopulate from real data). Otherwise returns { jobs, vms, orphans }
|
|
77
|
-
* with
|
|
77
|
+
* with these safety transforms applied to jobs:
|
|
78
78
|
*
|
|
79
|
-
* • `running` → `
|
|
80
|
-
*
|
|
81
|
-
*
|
|
79
|
+
* • `running` WITH a pending-job sidecar → `recovering` with a deferral
|
|
80
|
+
* marker, NOT `error`. A heavy job that stashed a recoverable sidecar
|
|
81
|
+
* (its cfg) must defer its final state to the startup reap, which
|
|
82
|
+
* actually checks gcloud — the blanket `error` was the improve39 VM-7
|
|
83
|
+
* bug: a mid-add restart stranded an instance that was really up as a
|
|
84
|
+
* dead `add-failed`. `recovering` is an honest "the reap will resolve
|
|
85
|
+
* this" surface; the reap then supersedes it (reconcile/half-provisioned
|
|
86
|
+
* /cleanup) once it has the gcloud verdict.
|
|
87
|
+
* • `running` WITHOUT a sidecar → `error` with a log marker, because the
|
|
88
|
+
* child process died with the dashboard and there's nothing to recover
|
|
89
|
+
* from — un-recoverable history, the old behavior.
|
|
82
90
|
* • `queued` → dropped, because the in-memory pendingJobQueue array
|
|
83
91
|
* is gone, so promoteFromJobQueue can never start them. The operator
|
|
84
92
|
* will re-click (the queued click had done no work yet).
|
|
85
93
|
*
|
|
94
|
+
* `sidecarKeys` is the set of `${action}-${n}` strings the caller read from
|
|
95
|
+
* the pending-jobs dir (server.js passes it; tests pass a plain array/Set).
|
|
96
|
+
* Omitted → no job has a sidecar → every running job downgrades to `error`
|
|
97
|
+
* exactly as before, so existing callers/tests are unaffected.
|
|
98
|
+
*
|
|
86
99
|
* Pure; no I/O. */
|
|
87
|
-
function restoreFromSnapshot({ snapshot, maxAgeMs, nowIso } = {}) {
|
|
100
|
+
function restoreFromSnapshot({ snapshot, maxAgeMs, nowIso, sidecarKeys } = {}) {
|
|
88
101
|
if (!snapshot || typeof snapshot !== 'object') return null;
|
|
89
102
|
if (typeof snapshot.timestamp !== 'string') return null;
|
|
90
103
|
const ts = Date.parse(snapshot.timestamp);
|
|
@@ -95,16 +108,33 @@ function restoreFromSnapshot({ snapshot, maxAgeMs, nowIso } = {}) {
|
|
|
95
108
|
if (!Number.isFinite(cap) || cap <= 0) return null;
|
|
96
109
|
if (ageMs > cap) return null;
|
|
97
110
|
|
|
111
|
+
const sidecars = sidecarKeys instanceof Set ? sidecarKeys : new Set(sidecarKeys || []);
|
|
98
112
|
const srcJobs = (snapshot.jobs && typeof snapshot.jobs === 'object') ? snapshot.jobs : {};
|
|
99
113
|
const jobs = {};
|
|
100
114
|
for (const [k, j] of Object.entries(srcJobs)) {
|
|
101
115
|
if (!j || typeof j !== 'object') continue;
|
|
102
116
|
if (j.state === 'queued') continue;
|
|
103
117
|
if (j.state === 'running') {
|
|
118
|
+
if (sidecars.has(`${j.action}-${k}`)) {
|
|
119
|
+
jobs[k] = {
|
|
120
|
+
...j,
|
|
121
|
+
state: 'recovering',
|
|
122
|
+
log: `${j.log ? j.log + '\n' : ''}(snapshot-restored — deferring to startup reap)`,
|
|
123
|
+
};
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
104
126
|
jobs[k] = {
|
|
105
127
|
...j,
|
|
106
128
|
state: 'error',
|
|
107
129
|
finishedAt: nowIso,
|
|
130
|
+
// improve40: mark this as a restart-orphan, NOT a genuine in-process
|
|
131
|
+
// failure. The job was `running` when the restart downgraded it and has
|
|
132
|
+
// NO sidecar to reconcile from — so the boot path must probe the VM's
|
|
133
|
+
// REAL provision state (orphanedAddsToReconcile → gcloud reap) rather
|
|
134
|
+
// than leave a dead `add-failed` badge on a VM that may have actually
|
|
135
|
+
// provisioned (the VM-7 hole). An add that failed IN-PROCESS keeps its
|
|
136
|
+
// own terminal `error` with no flag, so it is never re-probed/cleared.
|
|
137
|
+
restartOrphaned: true,
|
|
108
138
|
log: `${j.log ? j.log + '\n' : ''}(snapshot-restored — dashboard restarted before this finished)`,
|
|
109
139
|
};
|
|
110
140
|
continue;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Indexes the currently-reachable VMs by their live sessionId.
|
|
5
|
+
* @param {Array|Object} vms The merged VM states on the dashboard.
|
|
6
|
+
* @returns {Object} A map of sessionId -> VM number (n).
|
|
7
|
+
*/
|
|
8
|
+
function indexVmsBySessionId(vms) {
|
|
9
|
+
const map = {};
|
|
10
|
+
if (!vms) return map;
|
|
11
|
+
const list = Array.isArray(vms) ? vms : Object.values(vms);
|
|
12
|
+
for (const vm of list) {
|
|
13
|
+
if (vm && vm.sessionId && vm.n !== undefined) {
|
|
14
|
+
map[vm.sessionId] = vm.n;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return map;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Computes which dependencies are unsatisfied (i.e. not in the completedSlugs set).
|
|
22
|
+
* @param {string[]} dependsOn Prerequisite slugs.
|
|
23
|
+
* @param {Set|string[]} completedSlugs Set or array of completed plan slugs.
|
|
24
|
+
* @returns {string[]} The unsatisfied dependency slugs.
|
|
25
|
+
*/
|
|
26
|
+
function unsatisfiedDeps(dependsOn, completedSlugs) {
|
|
27
|
+
const deps = Array.isArray(dependsOn) ? dependsOn : [];
|
|
28
|
+
const completedSet = completedSlugs instanceof Set ? completedSlugs : new Set(completedSlugs || []);
|
|
29
|
+
return deps.filter(dep => !completedSet.has(dep));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves claims and dependency states for each queued plan.
|
|
34
|
+
* @param {Object} params
|
|
35
|
+
* @param {Object[]} params.plans Array of PlanSummary objects from /api/plans.
|
|
36
|
+
* @param {Object[]} params.claims Array of PlanClaim objects from /api/plan-claims.
|
|
37
|
+
* @param {Set|string[]} params.completedSlugs Completed plan slugs.
|
|
38
|
+
* @param {Object} params.vmsBySessionId Map of sessionId -> VM number.
|
|
39
|
+
* @returns {Object[]} Annotated plan rows.
|
|
40
|
+
*/
|
|
41
|
+
function resolvePlanRows({ plans, claims, completedSlugs, vmsBySessionId }) {
|
|
42
|
+
const list = Array.isArray(plans) ? plans : [];
|
|
43
|
+
const claimsList = Array.isArray(claims) ? claims : [];
|
|
44
|
+
|
|
45
|
+
return list.map(plan => {
|
|
46
|
+
// 1. Extract plan fields
|
|
47
|
+
const slug = plan.slug;
|
|
48
|
+
const title = plan.title;
|
|
49
|
+
const mode = plan.mode || null;
|
|
50
|
+
const order = plan.order != null ? plan.order : null;
|
|
51
|
+
const prefix = plan.prefix || null;
|
|
52
|
+
|
|
53
|
+
// 2. Resolve dependency-blocked state
|
|
54
|
+
const dependsOn = Array.isArray(plan.depends_on) ? plan.depends_on : (plan.dependsOn ? plan.dependsOn : []);
|
|
55
|
+
const blockedBy = unsatisfiedDeps(dependsOn, completedSlugs);
|
|
56
|
+
|
|
57
|
+
// 3. Resolve claim state
|
|
58
|
+
const claim = claimsList.find(c => c.slug === slug);
|
|
59
|
+
let claimInfo = null;
|
|
60
|
+
if (claim) {
|
|
61
|
+
const claimedBy = claim.claimedBy || claim.claimed_by || {};
|
|
62
|
+
const sessionId = claimedBy.sessionId || claimedBy.session_id || null;
|
|
63
|
+
const vm = (sessionId && vmsBySessionId && vmsBySessionId[sessionId] !== undefined) ? vmsBySessionId[sessionId] : null;
|
|
64
|
+
|
|
65
|
+
claimInfo = {
|
|
66
|
+
vm,
|
|
67
|
+
machine: claimedBy.machine || null,
|
|
68
|
+
agent: claimedBy.agentProvider || claimedBy.agent_provider || null,
|
|
69
|
+
sessionAlive: claim.sessionAlive !== undefined ? claim.sessionAlive : (claim.session_alive !== undefined ? claim.session_alive : null),
|
|
70
|
+
source: claim.source || null,
|
|
71
|
+
currentStep: claimedBy.currentStep || claimedBy.current_step || null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
slug,
|
|
77
|
+
title,
|
|
78
|
+
mode,
|
|
79
|
+
order,
|
|
80
|
+
prefix,
|
|
81
|
+
blockedBy,
|
|
82
|
+
claim: claimInfo
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const REQUIRED_PLAN_ROW_FIELDS = [
|
|
88
|
+
'slug', 'title', 'mode', 'order', 'prefix', 'blockedBy', 'claim'
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
indexVmsBySessionId,
|
|
93
|
+
unsatisfiedDeps,
|
|
94
|
+
resolvePlanRows,
|
|
95
|
+
REQUIRED_PLAN_ROW_FIELDS
|
|
96
|
+
};
|
package/npm/fleet-reconcile.js
CHANGED
|
@@ -33,4 +33,61 @@ function computeUnrostered(running, shown) {
|
|
|
33
33
|
.sort((a, b) => a - b);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
// Map a single degraded reason to the live health signal that resolves it.
|
|
37
|
+
// Returns true only when that signal now passes; a reason with no mapped signal
|
|
38
|
+
// returns false (retained — a genuine config problem, never a false-green).
|
|
39
|
+
// Start with the two observed classes (improve41); extend the table as new
|
|
40
|
+
// degraded reasons gain a corresponding live signal.
|
|
41
|
+
function reasonClearedByHealth(reason, { reachableServing, authed }) {
|
|
42
|
+
const r = String(reason || '');
|
|
43
|
+
if (/claude auth|claude\.json|login/i.test(r)) return authed;
|
|
44
|
+
if (/container|never came up|provisioning failed/i.test(r)) return reachableServing;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Given a terminal add-job badge and the VM's live health, return the corrected
|
|
49
|
+
// badge — or null when nothing changes. Conservative by construction: clears a
|
|
50
|
+
// degraded reason only when its live health signal now passes; flips a bare
|
|
51
|
+
// `error` add to `done` when the VM is reachable + serving; leaves any reason
|
|
52
|
+
// with no health signal intact (no false-green). The poll loop supplies the live
|
|
53
|
+
// facts (I/O at the edge) and applies the result; the verdict itself is pure.
|
|
54
|
+
// job: { state: 'done'|'error', degraded, degradedReasons }
|
|
55
|
+
// health: { reachable, editorHttpOk, providerAuthState }
|
|
56
|
+
// → null | { state, degraded, degradedReasons, transition }
|
|
57
|
+
function reconcileTerminalAddBadge({ job, health } = {}) {
|
|
58
|
+
if (!job || !health) return null;
|
|
59
|
+
const reachableServing = !!(health.reachable && health.editorHttpOk);
|
|
60
|
+
const authed = health.providerAuthState === 'authenticated';
|
|
61
|
+
|
|
62
|
+
// Bare `error` add (no degraded reasons): flip to `done` once the VM is
|
|
63
|
+
// reachable + serving — the VM3 case (a manual `cloud:up` recovered the editor
|
|
64
|
+
// outside the dashboard's view).
|
|
65
|
+
if (job.state === 'error') {
|
|
66
|
+
const reasons = job.degradedReasons || [];
|
|
67
|
+
if (reasons.length === 0 && reachableServing) {
|
|
68
|
+
return { state: 'done', degraded: false, degradedReasons: [], transition: 'error→done (editor 200)' };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Terminal `done` that is degraded: clear each reason whose live signal now
|
|
74
|
+
// passes; retain the rest. Drop `degraded` only when no reasons remain.
|
|
75
|
+
if (job.state === 'done' && job.degraded) {
|
|
76
|
+
const reasons = job.degradedReasons || [];
|
|
77
|
+
const remaining = reasons.filter((reason) => !reasonClearedByHealth(reason, { reachableServing, authed }));
|
|
78
|
+
if (remaining.length === reasons.length) return null; // nothing cleared → no-op
|
|
79
|
+
const cleared = reasons.length - remaining.length;
|
|
80
|
+
return {
|
|
81
|
+
state: 'done',
|
|
82
|
+
degraded: remaining.length > 0,
|
|
83
|
+
degradedReasons: remaining,
|
|
84
|
+
transition: remaining.length > 0
|
|
85
|
+
? `degraded: cleared ${cleared} reason(s), ${remaining.length} remain`
|
|
86
|
+
: 'degraded→clear (health restored)',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { parseRunningFleetVms, computeUnrostered, reconcileTerminalAddBadge };
|
package/npm/fleet-reset.js
CHANGED
|
@@ -31,30 +31,97 @@
|
|
|
31
31
|
// Best-effort: a no-remote / queue-disabled project legitimately exits
|
|
32
32
|
// non-zero, and we should not block the reset.
|
|
33
33
|
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
34
|
+
// Before the wipe we QUIESCE ALL WRITERS: park the editor server
|
|
35
|
+
// (`stop-server --hold-for-restart`) AND stop the PTY broker
|
|
36
|
+
// (`pty-broker stop`). Parking only the editor server is insufficient — the
|
|
37
|
+
// broker is a SEPARATE process (and hooks / agent subprocesses can fire), so
|
|
38
|
+
// it keeps re-creating `.codeyam/logs/` and `.codeyam/run/` between when the
|
|
39
|
+
// wipe enumerates the dir and when it removes it. That race surfaced live
|
|
40
|
+
// (VM-1, 2026-06-01) as `rm: cannot remove '.codeyam': Directory not empty`,
|
|
41
|
+
// aborting the whole reset. Both quiesce calls are fail-soft `|| true` so a
|
|
42
|
+
// server/broker that's already dead (e.g. the reset is rerun after a partial
|
|
43
|
+
// failure) doesn't block the chain.
|
|
44
|
+
//
|
|
45
|
+
// The wipe itself is RETRY-RESILIENT instead of a single `rm -rf .codeyam`
|
|
46
|
+
// that aborts the &&-chain on one transient re-creation: a bounded loop of
|
|
47
|
+
// `find .codeyam -mindepth 1 -delete` empties the contents and tolerates a
|
|
48
|
+
// brief re-create, then proceeds even if `.codeyam` is still non-empty after
|
|
49
|
+
// best-effort (a soft retry, never a hard failure — `git checkout`/`init`
|
|
50
|
+
// repopulates over whatever remains). Emptying contents (not removing the dir)
|
|
51
|
+
// also sidesteps the `rmdir` race entirely.
|
|
52
|
+
//
|
|
53
|
+
// The destructive region (park → broker-stop → wipe → restore) is made
|
|
54
|
+
// FAILURE-SAFE with an EXIT trap installed before the park and disarmed after
|
|
55
|
+
// a successful restore. If ANY step in that region aborts the chain, the trap
|
|
56
|
+
// still restores tracked `.codeyam` (or re-inits), copies the two preserved
|
|
57
|
+
// infra files back, and runs `restart-server --force-in-container` before
|
|
58
|
+
// exiting non-zero — so a failed deep-clean self-recovers to a working
|
|
59
|
+
// (un-reset, but UP) VM instead of leaving it parked-and-down with a
|
|
60
|
+
// half-wiped `.codeyam`, and still reports the failure. The happy path is
|
|
61
|
+
// unchanged: the restore steps run, the trap is disarmed, and the caller's
|
|
62
|
+
// own trailing `restart-server` resurrects the parked server.
|
|
45
63
|
function codeyamDeepCleanSteps(E) {
|
|
64
|
+
// Restore primitives shared by the happy path and the trap recovery body.
|
|
65
|
+
const restoreCodeyam = `git checkout HEAD -- .codeyam 2>/dev/null || ${E} init`;
|
|
66
|
+
const restoreElj =
|
|
67
|
+
'test -f /tmp/cy-elj.bak && mkdir -p .codeyam && cp /tmp/cy-elj.bak .codeyam/editor.local.json';
|
|
68
|
+
const restoreSs =
|
|
69
|
+
'test -f /tmp/cy-ss.bak && mkdir -p .codeyam && cp /tmp/cy-ss.bak .codeyam/server-state.json';
|
|
70
|
+
// Recovery body: restore .codeyam + the two infra backups, then resurrect the
|
|
71
|
+
// parked server. Runs ONLY on the failure path (the happy path's own restore
|
|
72
|
+
// steps remove the backups; recovery here intentionally leaves them in place
|
|
73
|
+
// because on a mid-region abort those steps never ran).
|
|
74
|
+
const recover =
|
|
75
|
+
`${restoreCodeyam}; ( ${restoreElj}; true ); ( ${restoreSs}; true ); ` +
|
|
76
|
+
`${E} editor restart-server --force-in-container || true`;
|
|
46
77
|
return [
|
|
47
78
|
`( ${E} editor branch-queue release-mine --yes 2>/dev/null; true )`,
|
|
48
79
|
'( cp .codeyam/editor.local.json /tmp/cy-elj.bak 2>/dev/null; true )',
|
|
49
80
|
'( cp .codeyam/server-state.json /tmp/cy-ss.bak 2>/dev/null; true )',
|
|
81
|
+
// A brace group (NOT a subshell) so the function + trap persist into the
|
|
82
|
+
// current shell where the rest of the &&-chain runs.
|
|
83
|
+
`{ __cy_recover() { ${recover}; }; trap '__cy_recover; exit 1' EXIT; }`,
|
|
50
84
|
`(${E} editor stop-server --hold-for-restart --force-in-container || true)`,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
85
|
+
`(${E} editor pty-broker stop || true)`,
|
|
86
|
+
'( 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 )',
|
|
87
|
+
`( ${restoreCodeyam} )`,
|
|
88
|
+
`( ${restoreElj} && rm -f /tmp/cy-elj.bak; true )`,
|
|
89
|
+
`( ${restoreSs} && rm -f /tmp/cy-ss.bak; true )`,
|
|
90
|
+
// Restore succeeded — disarm the failure-safe trap so normal completion
|
|
91
|
+
// (and the caller's trailing session-reset + restart) doesn't re-trigger it.
|
|
92
|
+
'trap - EXIT',
|
|
55
93
|
];
|
|
56
94
|
}
|
|
57
95
|
|
|
96
|
+
// Belt-and-suspenders broker-ensure, appended after the trailing
|
|
97
|
+
// `restart-server` in every reset recipe. The PRIMARY fix lives in the
|
|
98
|
+
// editor server's `ensure_broker_running`: it now `rm -f`s the stale
|
|
99
|
+
// socket and retries the spawn, so the reexec'd `start` (which
|
|
100
|
+
// restart-server triggers) reliably respawns the broker with no
|
|
101
|
+
// down-window. This step is the safety net for an OLDER in-container
|
|
102
|
+
// binary whose `restart-server` predates that fix — it mirrors the
|
|
103
|
+
// vm-watchdog's recovery so a reset still ends broker-up regardless of
|
|
104
|
+
// which binary is installed.
|
|
105
|
+
//
|
|
106
|
+
// Shape: status-gated and idempotent. `pty-broker status` exits non-zero
|
|
107
|
+
// only when no broker is reachable; ONLY then does the `||` branch fire
|
|
108
|
+
// (`rm -f` the stale socket — mirroring the watchdog — then `nohup` a
|
|
109
|
+
// detached daemon). When a broker is already alive the status succeeds
|
|
110
|
+
// and the respawn branch never runs, so a healthy broker is NEVER killed.
|
|
111
|
+
// Wrapped in `( …; true )` so it can never break the reset's &&-chain
|
|
112
|
+
// (a down broker that fails to respawn must not abort the rest of the
|
|
113
|
+
// reset). The socket glob + `pty-broker daemon` invocation match the
|
|
114
|
+
// watchdog (`scripts/operator/vm-watchdog.sh`) and the per-project
|
|
115
|
+
// `$TMPDIR`/`/tmp` socket layout (`socket_path_for`).
|
|
116
|
+
function codeyamBrokerEnsureStep(E) {
|
|
117
|
+
return (
|
|
118
|
+
`( ${E} editor pty-broker status >/dev/null 2>&1 || ` +
|
|
119
|
+
`( rm -f /tmp/codeyam-pty-broker-*.sock; ` +
|
|
120
|
+
`nohup ${E} editor pty-broker daemon </dev/null >/tmp/broker-spawn.log 2>&1 & disown ); ` +
|
|
121
|
+
'true )'
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
58
125
|
// Build the in-container bash for "Reset client" — hard-clean the CLIENT
|
|
59
126
|
// project (/workspace) on ANY VM shape. The decisions encoded here:
|
|
60
127
|
// - Reset to HEAD, NOT origin: `git reset --hard` (bare) discards uncommitted
|
|
@@ -85,6 +152,9 @@ function resetClientScript({ E, gitReseedBody }) {
|
|
|
85
152
|
`${E} editor session-reset`,
|
|
86
153
|
'echo ">> [4/5] restarting server to respawn the broker (no rebuild)"',
|
|
87
154
|
`(${E} editor restart-server --force-in-container || true)`,
|
|
155
|
+
// Belt-and-suspenders: ensure a broker is live even on an older binary
|
|
156
|
+
// whose restart-server doesn't yet respawn it. Idempotent / status-gated.
|
|
157
|
+
codeyamBrokerEnsureStep(E),
|
|
88
158
|
'echo ">> [5/5] re-seeding git credential cache"',
|
|
89
159
|
`( ( ${gitReseedBody} ) || echo "WARN git cache re-seed failed" )`,
|
|
90
160
|
].join(' && ');
|
|
@@ -138,7 +208,15 @@ function resetEditorStateScript({ E }) {
|
|
|
138
208
|
// docker-exec returns cleanly, so `|| true` keeps a broker-down VM green.
|
|
139
209
|
'echo ">> [4/4] restarting server to respawn the broker (no rebuild)"',
|
|
140
210
|
`(${E} editor restart-server --force-in-container || true)`,
|
|
211
|
+
// Belt-and-suspenders: ensure a broker is live even on an older binary
|
|
212
|
+
// whose restart-server doesn't yet respawn it. Idempotent / status-gated.
|
|
213
|
+
codeyamBrokerEnsureStep(E),
|
|
141
214
|
].join(' && ');
|
|
142
215
|
}
|
|
143
216
|
|
|
144
|
-
module.exports = {
|
|
217
|
+
module.exports = {
|
|
218
|
+
codeyamDeepCleanSteps,
|
|
219
|
+
codeyamBrokerEnsureStep,
|
|
220
|
+
resetClientScript,
|
|
221
|
+
resetEditorStateScript,
|
|
222
|
+
};
|