muse-crew 0.7.20 → 0.8.0
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/docs/guide.md +179 -7
- package/lib/AGENTS.md +2 -1
- package/lib/build-registry.js +3 -2
- package/lib/publish-npm.sh +33 -0
- package/lib/test-publish-preflight.sh +210 -0
- package/lib/test-worktree-backend.sh +51 -1
- package/lib/update-watch.js +633 -0
- package/lib/worktree-lifecycle.sh +68 -10
- package/package.json +1 -1
- package/seed/AGENTS.md +1 -0
- package/seed/cron-body-update-watch.md +13 -0
- package/seed/crons.json +14 -1
- package/seed/workflows/upgrade.md +21 -0
- package/workflows/AGENTS.md +1 -1
- package/workflows/bugfix.js +239 -14
- package/workflows/chore.js +44 -12
- package/workflows/crew-dispatch.js +43 -6
- package/workflows/crew-init.js +173 -10
- package/workflows/standard.js +44 -12
- package/workflows/upgrade.js +794 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lib/update-watch.js — the automatic update watcher (deterministic, no LLM
|
|
3
|
+
// judgment). Run daily by the crew-update-watch cron. Watches two things:
|
|
4
|
+
//
|
|
5
|
+
// 1. Crew: public npm registry (npm view muse-crew version) vs the live
|
|
6
|
+
// release (crew-release.sh current). If newer and policy/channel allow,
|
|
7
|
+
// file a workflow="upgrade" task with source: npm@<version>.
|
|
8
|
+
// 2. Dashboard: git ls-remote origin HEAD on the first deploy_type=artifact
|
|
9
|
+
// project vs the recorded sha in $CREW_HOME/.update-watch.json. If
|
|
10
|
+
// newer and policy allows, read the .crew-version compatibility anchor
|
|
11
|
+
// declared at the new ref (git fetch origin + git show <sha>:.crew-version)
|
|
12
|
+
// and file a workflow="chore" task carrying the dashboard-upgrade journey
|
|
13
|
+
// (old -> new commits). When the anchor declares a newer crew than the
|
|
14
|
+
// running release, the crew upgrade task is filed FIRST and the dashboard
|
|
15
|
+
// task notes that it follows the crew upgrade (dashboard-led ordering).
|
|
16
|
+
//
|
|
17
|
+
// Safety invariants (hard):
|
|
18
|
+
// - The watcher only FILES tasks. It never deploys, never touches the
|
|
19
|
+
// artifact, never mutates config, never touches the scheduler.
|
|
20
|
+
// - The npm source is the pinned public registry URL only.
|
|
21
|
+
// - Every check failure is logged and reported — never thrown. Exit 0 on
|
|
22
|
+
// every path except a missing --crew-home (exit 2).
|
|
23
|
+
"use strict";
|
|
24
|
+
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
const path = require("path");
|
|
27
|
+
const { execFileSync } = require("child_process");
|
|
28
|
+
|
|
29
|
+
const STATE_FILE = ".update-watch.json";
|
|
30
|
+
const LOG_FILE = "update-watch.log";
|
|
31
|
+
const NPM_REGISTRY = "https://registry.npmjs.org/";
|
|
32
|
+
const SHA40 = /^[0-9a-f]{40}$/;
|
|
33
|
+
|
|
34
|
+
// ── Pure functions (no IO — exported for tests) ──────────────────────
|
|
35
|
+
|
|
36
|
+
function parseSemver(v) {
|
|
37
|
+
if (typeof v !== "string") return null;
|
|
38
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(v.trim());
|
|
39
|
+
if (!m) return null;
|
|
40
|
+
return {
|
|
41
|
+
major: parseInt(m[1], 10),
|
|
42
|
+
minor: parseInt(m[2], 10),
|
|
43
|
+
patch: parseInt(m[3], 10),
|
|
44
|
+
prerelease: m[4] || null,
|
|
45
|
+
raw: v.trim()
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function compareSemver(a, b) {
|
|
50
|
+
const keys = ["major", "minor", "patch"];
|
|
51
|
+
for (let i = 0; i < keys.length; i++) {
|
|
52
|
+
if (a[keys[i]] !== b[keys[i]]) return a[keys[i]] < b[keys[i]] ? -1 : 1;
|
|
53
|
+
}
|
|
54
|
+
if (a.prerelease === b.prerelease) return 0;
|
|
55
|
+
if (a.prerelease === null) return 1; // a plain release outranks a prerelease
|
|
56
|
+
if (b.prerelease === null) return -1;
|
|
57
|
+
if (a.prerelease === b.prerelease) return 0;
|
|
58
|
+
return a.prerelease < b.prerelease ? -1 : 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function channelAllows(channel, current, latest) {
|
|
62
|
+
if (compareSemver(latest, current) <= 0) return false;
|
|
63
|
+
if (channel === "patch") {
|
|
64
|
+
return current.major === latest.major && current.minor === latest.minor;
|
|
65
|
+
}
|
|
66
|
+
return true; // "latest"; unknown values are normalized to latest at policy read
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// { autoUpdate, current, latest, channel, lastFiled } -> { action, reason }
|
|
70
|
+
// current/latest are parseSemver results (or null when unobtainable),
|
|
71
|
+
// lastFiled is state.crew.last_filed_version (or undefined).
|
|
72
|
+
function crewDecision(opts) {
|
|
73
|
+
if (!opts.autoUpdate) return { action: "skip", reason: "policy-off" };
|
|
74
|
+
if (opts.current === null) return { action: "skip", reason: "not-an-npm-release" };
|
|
75
|
+
if (opts.latest === null) return { action: "skip", reason: "npm-check-failed" };
|
|
76
|
+
if (compareSemver(opts.latest, opts.current) <= 0) return { action: "skip", reason: "current" };
|
|
77
|
+
if (!channelAllows(opts.channel, opts.current, opts.latest)) return { action: "skip", reason: "channel" };
|
|
78
|
+
if (opts.lastFiled === opts.latest.raw) return { action: "skip", reason: "already-filed" };
|
|
79
|
+
return { action: "file", reason: "newer" };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// { autoUpdate, recordedSha, remoteSha } -> { action, reason }
|
|
83
|
+
function dashboardDecision(opts) {
|
|
84
|
+
if (!opts.autoUpdate) return { action: "skip", reason: "policy-off" };
|
|
85
|
+
if (!opts.remoteSha) return { action: "skip", reason: "ls-remote-failed" };
|
|
86
|
+
if (opts.recordedSha === opts.remoteSha) return { action: "skip", reason: "already-filed" };
|
|
87
|
+
return { action: "file", reason: "newer" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// { autoUpdate, current, declared, lastFiledCrew } ->
|
|
91
|
+
// { action: "file-first" | "dashboard-only" | "skip", reason }
|
|
92
|
+
// current/declared are parseSemver results (or null when unobtainable);
|
|
93
|
+
// lastFiledCrew is state.crew.last_filed_version (or undefined).
|
|
94
|
+
//
|
|
95
|
+
// The compatibility-anchor decision: a dashboard release declares the crew
|
|
96
|
+
// version it needs via .crew-version; the watcher treats (dashboard ref,
|
|
97
|
+
// crew version) as one unit and orders upgrades dashboard-led.
|
|
98
|
+
//
|
|
99
|
+
// A declared requirement bypasses update_channel (requirement, not
|
|
100
|
+
// preference) but not the auto_update_crew=false policy opt-out (a hard
|
|
101
|
+
// human decision — the dashboard task then notes the required version for
|
|
102
|
+
// a human). A declared older crew is a downgrade: a human decision, never
|
|
103
|
+
// filed automatically.
|
|
104
|
+
function crewVersionOrdering(opts) {
|
|
105
|
+
if (opts.declared === null) return { action: "dashboard-only", reason: "anchor-unavailable" };
|
|
106
|
+
if (opts.current === null) return { action: "dashboard-only", reason: "not-an-npm-release" };
|
|
107
|
+
const cmp = compareSemver(opts.declared, opts.current);
|
|
108
|
+
if (cmp < 0) return { action: "skip", reason: "downgrade" };
|
|
109
|
+
if (cmp === 0) return { action: "dashboard-only", reason: "current" };
|
|
110
|
+
if (!opts.autoUpdate) return { action: "dashboard-only", reason: "policy-off" };
|
|
111
|
+
if (opts.lastFiledCrew === opts.declared.raw) return { action: "dashboard-only", reason: "already-filed" };
|
|
112
|
+
return { action: "file-first", reason: "newer" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── IO helpers (take crewHome so tests use a tmp dir) ────────────────
|
|
116
|
+
|
|
117
|
+
function stateFilePath(crewHome) { return path.join(crewHome, STATE_FILE); }
|
|
118
|
+
function logFilePath(crewHome) { return path.join(crewHome, LOG_FILE); }
|
|
119
|
+
|
|
120
|
+
function appendLog(crewHome, level, message) {
|
|
121
|
+
const line = new Date().toISOString() + " " + level + " " + String(message) + "\n";
|
|
122
|
+
try {
|
|
123
|
+
fs.appendFileSync(logFilePath(crewHome), line, "utf8");
|
|
124
|
+
} catch (e) {
|
|
125
|
+
// The log is observability, never a gate: a read-only crew home must not
|
|
126
|
+
// stop the check. stdout still carries the summary.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readState(crewHome) {
|
|
131
|
+
let raw;
|
|
132
|
+
try {
|
|
133
|
+
raw = fs.readFileSync(stateFilePath(crewHome), "utf8");
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return {}; // missing file: first run
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(raw);
|
|
139
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
140
|
+
} catch (e) {
|
|
141
|
+
// fall through to the corrupt path below
|
|
142
|
+
}
|
|
143
|
+
appendLog(crewHome, "error", "state file corrupt — starting fresh");
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function writeState(crewHome, state) {
|
|
148
|
+
const p = stateFilePath(crewHome);
|
|
149
|
+
const tmp = p + ".tmp." + process.pid;
|
|
150
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
151
|
+
fs.renameSync(tmp, p); // atomic swap
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function firstLine(e) {
|
|
155
|
+
const s = String((e && e.message) || e || "");
|
|
156
|
+
return s.split("\n")[0].slice(0, 200);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Reads the .crew-version compatibility anchor at the filed remote ref.
|
|
160
|
+
// The declaration belongs to the release being evaluated, so it is read from
|
|
161
|
+
// the fetched remote object (git show <sha>:.crew-version), never from the
|
|
162
|
+
// local working tree — the checkout still sits at the recorded sha while the
|
|
163
|
+
// watcher evaluates a newer remote ref, so a working-tree read would answer
|
|
164
|
+
// the old release's declaration (exactly the staleness the anchor exists to
|
|
165
|
+
// fix).
|
|
166
|
+
//
|
|
167
|
+
// Returns the parseSemver result, or null when the anchor is missing,
|
|
168
|
+
// unreadable, or invalid: the dashboard leg then proceeds as today (fail open
|
|
169
|
+
// on the dashboard leg — never block updates on a missing declaration).
|
|
170
|
+
async function readCrewAnchor(deps, crewHome, repoPath, sha) {
|
|
171
|
+
try {
|
|
172
|
+
await deps.gitFetch(repoPath);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
appendLog(crewHome, "info", "dashboard: git fetch origin failed (" + firstLine(e) + ") — .crew-version unreadable, proceeding as today");
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
let raw;
|
|
178
|
+
try {
|
|
179
|
+
raw = String(await deps.gitShow(repoPath, sha, ".crew-version")).trim();
|
|
180
|
+
} catch (e) {
|
|
181
|
+
appendLog(crewHome, "info", "dashboard: no .crew-version at " + sha.slice(0, 7) + " (" + firstLine(e) + ") — proceeding as today");
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
const parsed = parseSemver(raw);
|
|
185
|
+
if (!parsed) {
|
|
186
|
+
appendLog(crewHome, "info", 'dashboard: .crew-version at ' + sha.slice(0, 7) + ' is not strict semver ("' + raw.slice(0, 80) + '") — proceeding as today');
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
appendLog(crewHome, "info", "dashboard: .crew-version at " + sha.slice(0, 7) + " declares crew " + parsed.raw);
|
|
190
|
+
return parsed;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Crew upgrade filing (shared by both legs) ────────────────────────
|
|
194
|
+
|
|
195
|
+
// Files one crew upgrade task and records it in state (idempotency marker
|
|
196
|
+
// last_filed_version, written at file time). Shared by the crew leg (its own
|
|
197
|
+
// npm-vs-current check) and the dashboard leg (dashboard-led ordering).
|
|
198
|
+
// opts: { current, latest (parseSemver), body } — body is the leg-specific
|
|
199
|
+
// description text that follows the pinned "source: npm@<v>" first line.
|
|
200
|
+
// Returns the filed task id, or null when nothing was filed.
|
|
201
|
+
async function fileCrewUpgrade(deps, ctx, summary, opts) {
|
|
202
|
+
const crewHome = ctx.crewHome;
|
|
203
|
+
const state = ctx.state;
|
|
204
|
+
|
|
205
|
+
let proj;
|
|
206
|
+
try {
|
|
207
|
+
proj = await deps.getProject("muse-crew");
|
|
208
|
+
} catch (e) {
|
|
209
|
+
proj = null;
|
|
210
|
+
}
|
|
211
|
+
if (!proj) {
|
|
212
|
+
const msg = "crew: no muse-crew project registered — skipping";
|
|
213
|
+
appendLog(crewHome, "skip", msg);
|
|
214
|
+
summary.push("update-watch: " + msg);
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const description =
|
|
219
|
+
"source: npm@" + opts.latest.raw + "\n" +
|
|
220
|
+
"\n" +
|
|
221
|
+
opts.body;
|
|
222
|
+
try {
|
|
223
|
+
const task = await deps.createTask({
|
|
224
|
+
project: "muse-crew",
|
|
225
|
+
workflow: "upgrade",
|
|
226
|
+
filed_by: "update-watch",
|
|
227
|
+
priority: "normal",
|
|
228
|
+
title: "Upgrade crew to pkg-" + opts.latest.raw,
|
|
229
|
+
description: description
|
|
230
|
+
});
|
|
231
|
+
state.crew = { last_filed_version: opts.latest.raw };
|
|
232
|
+
writeState(crewHome, state);
|
|
233
|
+
const taskId = task && task.id ? task.id : "(id unknown)";
|
|
234
|
+
const msg = "filed crew upgrade task " + taskId + " (pkg-" + opts.current.raw + " -> pkg-" + opts.latest.raw + ")";
|
|
235
|
+
appendLog(crewHome, "filed", msg);
|
|
236
|
+
summary.push("update-watch: " + msg);
|
|
237
|
+
return taskId;
|
|
238
|
+
} catch (e) {
|
|
239
|
+
const msg = "crew: create-task failed (" + firstLine(e) + ")";
|
|
240
|
+
appendLog(crewHome, "error", msg);
|
|
241
|
+
summary.push("update-watch: " + msg);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Crew check ────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
async function crewCheck(deps, ctx, summary) {
|
|
249
|
+
const crewHome = ctx.crewHome;
|
|
250
|
+
const state = ctx.state;
|
|
251
|
+
const channel = ctx.channel;
|
|
252
|
+
|
|
253
|
+
let currentRaw;
|
|
254
|
+
try {
|
|
255
|
+
currentRaw = String(await deps.readCurrent()).trim();
|
|
256
|
+
} catch (e) {
|
|
257
|
+
const msg = "crew: could not read current release (" + firstLine(e) + ")";
|
|
258
|
+
appendLog(crewHome, "error", msg);
|
|
259
|
+
summary.push("update-watch: " + msg);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const current = parseSemver(currentRaw.replace(/^pkg-/, ""));
|
|
263
|
+
if (!current) {
|
|
264
|
+
const msg = 'crew: current release "' + currentRaw + '" is not an npm release — skipping crew check';
|
|
265
|
+
appendLog(crewHome, "skip", msg);
|
|
266
|
+
summary.push("update-watch: " + msg);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let latestRaw;
|
|
271
|
+
try {
|
|
272
|
+
latestRaw = String(await deps.npmView()).trim();
|
|
273
|
+
} catch (e) {
|
|
274
|
+
const msg = "crew: npm check failed (" + firstLine(e) + ")";
|
|
275
|
+
appendLog(crewHome, "error", msg);
|
|
276
|
+
summary.push("update-watch: " + msg);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const latest = parseSemver(latestRaw);
|
|
280
|
+
if (!latest) {
|
|
281
|
+
const msg = 'crew: npm check failed (unparseable version "' + latestRaw + '")';
|
|
282
|
+
appendLog(crewHome, "error", msg);
|
|
283
|
+
summary.push("update-watch: " + msg);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const lastFiled = state.crew && state.crew.last_filed_version;
|
|
288
|
+
const decision = crewDecision({ autoUpdate: true, current: current, latest: latest, channel: channel, lastFiled: lastFiled });
|
|
289
|
+
if (decision.action === "skip") {
|
|
290
|
+
const msg = "crew pkg-" + current.raw + " -> npm " + latest.raw + " (no-op: " + decision.reason + ")";
|
|
291
|
+
appendLog(crewHome, "skip", msg);
|
|
292
|
+
summary.push("update-watch: " + msg);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
await fileCrewUpgrade(deps, { crewHome: crewHome, state: state }, summary, {
|
|
297
|
+
current: current,
|
|
298
|
+
latest: latest,
|
|
299
|
+
body:
|
|
300
|
+
"Automatic crew update: pkg-" + current.raw + " -> pkg-" + latest.raw + ".\n" +
|
|
301
|
+
"Filed by the update watcher (policy auto_update_crew, channel " + channel + ").\n" +
|
|
302
|
+
"The upgrade workflow deploys and verifies; this task is the trigger only."
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Dashboard check ──────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
async function dashboardCheck(deps, ctx, summary) {
|
|
309
|
+
const crewHome = ctx.crewHome;
|
|
310
|
+
const state = ctx.state;
|
|
311
|
+
|
|
312
|
+
let projects;
|
|
313
|
+
try {
|
|
314
|
+
projects = await deps.listProjects();
|
|
315
|
+
} catch (e) {
|
|
316
|
+
const msg = "dashboard: list-projects failed (" + firstLine(e) + ")";
|
|
317
|
+
appendLog(crewHome, "error", msg);
|
|
318
|
+
summary.push("update-watch: " + msg);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const proj = (projects || []).find(function (p) {
|
|
322
|
+
return p && p.deploy_type === "artifact" && p.repo_path;
|
|
323
|
+
});
|
|
324
|
+
if (!proj) {
|
|
325
|
+
const msg = "dashboard: no artifact project — skipping";
|
|
326
|
+
appendLog(crewHome, "skip", msg);
|
|
327
|
+
summary.push("update-watch: " + msg);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const projectId = proj.id || proj.project_id;
|
|
331
|
+
|
|
332
|
+
let remote;
|
|
333
|
+
try {
|
|
334
|
+
remote = String(await deps.gitConfigGet(proj.repo_path, "remote.origin.url")).trim();
|
|
335
|
+
} catch (e) {
|
|
336
|
+
remote = "";
|
|
337
|
+
}
|
|
338
|
+
if (!remote) {
|
|
339
|
+
const msg = "dashboard: no origin remote on " + proj.repo_path + " — skipping";
|
|
340
|
+
appendLog(crewHome, "skip", msg);
|
|
341
|
+
summary.push("update-watch: " + msg);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let sha = "";
|
|
346
|
+
let lsErr = null;
|
|
347
|
+
try {
|
|
348
|
+
sha = String(await deps.lsRemote(remote)).trim().split(/\s+/)[0] || "";
|
|
349
|
+
} catch (e) {
|
|
350
|
+
lsErr = firstLine(e);
|
|
351
|
+
}
|
|
352
|
+
if (!SHA40.test(sha)) {
|
|
353
|
+
const msg = "dashboard: ls-remote failed (" + (lsErr || "unparseable output") + ")";
|
|
354
|
+
appendLog(crewHome, "error", msg);
|
|
355
|
+
summary.push("update-watch: " + msg);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const recorded = (state.dashboard && state.dashboard[projectId] && state.dashboard[projectId].last_filed_sha) || null;
|
|
360
|
+
const decision = dashboardDecision({ autoUpdate: true, recordedSha: recorded, remoteSha: sha });
|
|
361
|
+
if (decision.action === "skip") {
|
|
362
|
+
const msg = "dashboard " + projectId + " " + sha.slice(0, 7) + " -> " + sha.slice(0, 7) + " (no-op: " + decision.reason + ")";
|
|
363
|
+
appendLog(crewHome, "skip", msg);
|
|
364
|
+
summary.push("update-watch: " + msg);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const oldSha = recorded || "unknown";
|
|
369
|
+
const oldSha7 = recorded ? recorded.slice(0, 7) : "unknown";
|
|
370
|
+
|
|
371
|
+
// Compatibility anchor (.crew-version): the release at `sha` declares the
|
|
372
|
+
// crew version it needs. Read at the filed ref, never the working tree.
|
|
373
|
+
const declared = await readCrewAnchor(deps, crewHome, proj.repo_path, sha);
|
|
374
|
+
|
|
375
|
+
let current = null;
|
|
376
|
+
if (declared) {
|
|
377
|
+
let currentRaw;
|
|
378
|
+
try {
|
|
379
|
+
currentRaw = String(await deps.readCurrent()).trim();
|
|
380
|
+
} catch (e) {
|
|
381
|
+
appendLog(crewHome, "info", "dashboard: could not read current release (" + firstLine(e) + ") — anchor unevaluable, proceeding as today");
|
|
382
|
+
}
|
|
383
|
+
if (currentRaw !== undefined) {
|
|
384
|
+
current = parseSemver(currentRaw.replace(/^pkg-/, ""));
|
|
385
|
+
if (!current) {
|
|
386
|
+
appendLog(crewHome, "info", 'dashboard: running crew "' + currentRaw.slice(0, 40) + '" is not an npm release — anchor unevaluable, proceeding as today');
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const ordering = crewVersionOrdering({
|
|
392
|
+
autoUpdate: ctx.autoUpdateCrew,
|
|
393
|
+
current: current,
|
|
394
|
+
declared: declared,
|
|
395
|
+
lastFiledCrew: state.crew && state.crew.last_filed_version
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
if (ordering.action === "skip") {
|
|
399
|
+
const msg = "dashboard " + projectId + " " + oldSha7 + " -> " + sha.slice(0, 7) +
|
|
400
|
+
" skipped: .crew-version declares crew " + declared.raw +
|
|
401
|
+
" older than running pkg-" + current.raw + " (a downgrade is a human decision)";
|
|
402
|
+
appendLog(crewHome, "skip", msg);
|
|
403
|
+
summary.push("update-watch: " + msg);
|
|
404
|
+
return; // state untouched — a downgrade is never filed automatically
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Dashboard-led ordering: file the required crew upgrade first, so the
|
|
408
|
+
// dashboard task can note that it follows the crew upgrade.
|
|
409
|
+
let crewTaskId = null;
|
|
410
|
+
if (ordering.action === "file-first") {
|
|
411
|
+
crewTaskId = await fileCrewUpgrade(deps, { crewHome: crewHome, state: state }, summary, {
|
|
412
|
+
current: current,
|
|
413
|
+
latest: declared,
|
|
414
|
+
body:
|
|
415
|
+
"Dashboard-led crew update: pkg-" + current.raw + " -> pkg-" + declared.raw + ".\n" +
|
|
416
|
+
"Required by dashboard " + projectId + " release " + sha.slice(0, 7) + " (.crew-version).\n" +
|
|
417
|
+
"Filed by the update watcher (policy auto_update_crew; a declared requirement, not an update_channel preference).\n" +
|
|
418
|
+
"The upgrade workflow deploys and verifies; this task is the trigger only.\n" +
|
|
419
|
+
"The dashboard upgrade to " + sha.slice(0, 7) + " is filed as its own task and must follow this one."
|
|
420
|
+
});
|
|
421
|
+
if (!crewTaskId) {
|
|
422
|
+
// The crew upgrade could not be filed (logged inside fileCrewUpgrade).
|
|
423
|
+
// The (dashboard ref, crew version) unit cannot be ordered — hold the
|
|
424
|
+
// dashboard leg rather than filing an upgrade the crew cannot run.
|
|
425
|
+
const msg = "dashboard " + projectId + " " + sha.slice(0, 7) +
|
|
426
|
+
" held: crew upgrade to pkg-" + declared.raw + " could not be filed — dashboard task not filed";
|
|
427
|
+
appendLog(crewHome, "error", msg);
|
|
428
|
+
summary.push("update-watch: " + msg);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Ordering note for the dashboard task, inserted right after the pinned
|
|
434
|
+
// "source: dashboard@<sha>" first line.
|
|
435
|
+
let orderingNote = "";
|
|
436
|
+
if (ordering.action === "file-first") {
|
|
437
|
+
orderingNote =
|
|
438
|
+
"Ordering: this dashboard release declares crew " + declared.raw + " (.crew-version).\n" +
|
|
439
|
+
"The crew upgrade (task " + crewTaskId + ") was filed first and must land before this dashboard upgrade proceeds.";
|
|
440
|
+
} else if (ordering.reason === "already-filed") {
|
|
441
|
+
orderingNote =
|
|
442
|
+
"Ordering: this dashboard release declares crew " + declared.raw + " (.crew-version).\n" +
|
|
443
|
+
"The crew upgrade task was already filed and must land before this dashboard upgrade proceeds.";
|
|
444
|
+
} else if (ordering.reason === "policy-off") {
|
|
445
|
+
orderingNote =
|
|
446
|
+
"Note: this dashboard release declares crew " + declared.raw + " (.crew-version),\n" +
|
|
447
|
+
"but auto_update_crew=false — a human must upgrade the crew to pkg-" + declared.raw + " first.";
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const description =
|
|
451
|
+
"source: dashboard@" + sha + "\n" +
|
|
452
|
+
(orderingNote === "" ? "" : "\n" + orderingNote + "\n") +
|
|
453
|
+
"\n" +
|
|
454
|
+
"Automatic dashboard upgrade: " + oldSha7 + " -> " + sha.slice(0, 7) + ".\n" +
|
|
455
|
+
"Filed by the update watcher (policy auto_update_dashboard).\n" +
|
|
456
|
+
"\n" +
|
|
457
|
+
"Journey (execute mechanically, no judgment):\n" +
|
|
458
|
+
"1. In " + proj.repo_path + ": git fetch origin && git rev-parse origin/HEAD — must\n" +
|
|
459
|
+
" match " + sha + ". Mismatch → park the task and say so.\n" +
|
|
460
|
+
"2. Ancestor check: git merge-base --is-ancestor " + oldSha + " " + sha + ". If " + oldSha + "\n" +
|
|
461
|
+
" is unknown or not an ancestor (force-push / history rewrite) → park\n" +
|
|
462
|
+
" the task and say so. Never upgrade across a rewritten history blind.\n" +
|
|
463
|
+
"3. Review: git log " + oldSha + ".." + sha + " --oneline and git diff --stat\n" +
|
|
464
|
+
" " + oldSha + " " + sha + ". Anything unrelated or suspicious → park and say so.\n" +
|
|
465
|
+
"4. Check out " + sha + " in the working copy and let this chore's Publish phase\n" +
|
|
466
|
+
" carry the artifact update (the normal publish path — no side channels).";
|
|
467
|
+
try {
|
|
468
|
+
const task = await deps.createTask({
|
|
469
|
+
project: projectId,
|
|
470
|
+
workflow: "chore",
|
|
471
|
+
filed_by: "update-watch",
|
|
472
|
+
priority: "normal",
|
|
473
|
+
title: "Dashboard upgrade to " + sha.slice(0, 7),
|
|
474
|
+
description: description
|
|
475
|
+
});
|
|
476
|
+
state.dashboard = state.dashboard || {};
|
|
477
|
+
state.dashboard[projectId] = { last_filed_sha: sha };
|
|
478
|
+
writeState(crewHome, state);
|
|
479
|
+
const taskId = task && task.id ? task.id : "(id unknown)";
|
|
480
|
+
const msg = "filed dashboard upgrade task " + taskId + " (" + oldSha7 + " -> " + sha.slice(0, 7) + ")";
|
|
481
|
+
appendLog(crewHome, "filed", msg);
|
|
482
|
+
summary.push("update-watch: " + msg);
|
|
483
|
+
} catch (e) {
|
|
484
|
+
const msg = "dashboard: create-task failed (" + firstLine(e) + ")";
|
|
485
|
+
appendLog(crewHome, "error", msg);
|
|
486
|
+
summary.push("update-watch: " + msg);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ── Pipeline ─────────────────────────────────────────────────────────
|
|
491
|
+
|
|
492
|
+
async function run(deps) {
|
|
493
|
+
const crewHome = deps.crewHome;
|
|
494
|
+
const summary = [];
|
|
495
|
+
const state = readState(crewHome);
|
|
496
|
+
appendLog(crewHome, "info", "watch run started");
|
|
497
|
+
|
|
498
|
+
let config = {};
|
|
499
|
+
try {
|
|
500
|
+
config = (await deps.getConfig()) || {};
|
|
501
|
+
} catch (e) {
|
|
502
|
+
const msg = "config read failed (" + firstLine(e) + ") — checks skipped";
|
|
503
|
+
appendLog(crewHome, "error", msg);
|
|
504
|
+
summary.push("update-watch: " + msg);
|
|
505
|
+
appendLog(crewHome, "info", "watch run finished");
|
|
506
|
+
return { summary: summary };
|
|
507
|
+
}
|
|
508
|
+
const autoUpdateCrew = config.auto_update_crew !== "false";
|
|
509
|
+
const autoUpdateDashboard = config.auto_update_dashboard !== "false";
|
|
510
|
+
let channel = config.update_channel || "latest";
|
|
511
|
+
if (channel !== "latest" && channel !== "patch") {
|
|
512
|
+
appendLog(crewHome, "info", 'unknown update_channel "' + channel + '", treating as latest');
|
|
513
|
+
channel = "latest";
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (!autoUpdateCrew) {
|
|
517
|
+
const msg = "crew check skipped (auto_update_crew=false)";
|
|
518
|
+
appendLog(crewHome, "skip", "crew: policy off (auto_update_crew=false)");
|
|
519
|
+
summary.push("update-watch: " + msg);
|
|
520
|
+
} else {
|
|
521
|
+
await crewCheck(deps, { crewHome: crewHome, state: state, channel: channel }, summary);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (!autoUpdateDashboard) {
|
|
525
|
+
const msg = "dashboard check skipped (auto_update_dashboard=false)";
|
|
526
|
+
appendLog(crewHome, "skip", "dashboard: policy off (auto_update_dashboard=false)");
|
|
527
|
+
summary.push("update-watch: " + msg);
|
|
528
|
+
} else {
|
|
529
|
+
await dashboardCheck(deps, { crewHome: crewHome, state: state, autoUpdateCrew: autoUpdateCrew }, summary);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
appendLog(crewHome, "info", "watch run finished");
|
|
533
|
+
return { summary: summary };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ── Real deps (shell out; built once in main) ────────────────────────
|
|
537
|
+
|
|
538
|
+
function shOut(cmd, args, timeoutMs) {
|
|
539
|
+
return execFileSync(cmd, args, { encoding: "utf8", timeout: timeoutMs || 30000 }).toString();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function apiCall(crewHome, command, jsonArgs) {
|
|
543
|
+
const args = [
|
|
544
|
+
path.join(crewHome, "current", "lib", "crew-api.js"),
|
|
545
|
+
"--crew-home", crewHome,
|
|
546
|
+
command
|
|
547
|
+
];
|
|
548
|
+
if (jsonArgs !== undefined) args.push("--json", JSON.stringify(jsonArgs));
|
|
549
|
+
const out = shOut(process.execPath, args);
|
|
550
|
+
return JSON.parse(out);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function buildRealDeps(crewHome) {
|
|
554
|
+
return {
|
|
555
|
+
crewHome: crewHome,
|
|
556
|
+
getConfig: function () { return apiCall(crewHome, "get-config").config || {}; },
|
|
557
|
+
readCurrent: function () {
|
|
558
|
+
return shOut("/bin/bash", [path.join(crewHome, "crew-release.sh"), "current", crewHome]).trim();
|
|
559
|
+
},
|
|
560
|
+
npmView: function () {
|
|
561
|
+
// Pinned to the public registry — never a local path, never a git URL.
|
|
562
|
+
return shOut("npm", ["view", "muse-crew", "version", "--registry", NPM_REGISTRY]).trim();
|
|
563
|
+
},
|
|
564
|
+
getProject: function (id) { return apiCall(crewHome, "get-project", { id: id }).project; },
|
|
565
|
+
listProjects: function () { return apiCall(crewHome, "list-projects").projects; },
|
|
566
|
+
gitConfigGet: function (repoPath, key) {
|
|
567
|
+
return shOut("git", ["-C", repoPath, "config", "--get", key]).trim();
|
|
568
|
+
},
|
|
569
|
+
lsRemote: function (remote) {
|
|
570
|
+
try {
|
|
571
|
+
const out = shOut("git", ["ls-remote", remote, "HEAD"]);
|
|
572
|
+
return (out.trim().split(/\s+/)[0] || "");
|
|
573
|
+
} catch (e) {
|
|
574
|
+
const first = String((e && e.stderr) || (e && e.message) || e || "").split("\n")[0].slice(0, 200);
|
|
575
|
+
throw new Error("ls-remote failed (" + first + ")");
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
gitFetch: function (repoPath) {
|
|
579
|
+
// Throws on failure; the caller fails open.
|
|
580
|
+
return shOut("git", ["-C", repoPath, "fetch", "origin"]).trim();
|
|
581
|
+
},
|
|
582
|
+
gitShow: function (repoPath, sha, filePath) {
|
|
583
|
+
// Throws when the ref or file is missing; the caller fails open.
|
|
584
|
+
// sha is SHA40-validated before it reaches here; filePath is the
|
|
585
|
+
// constant ".crew-version".
|
|
586
|
+
return shOut("git", ["-C", repoPath, "show", sha + ":" + filePath]).trim();
|
|
587
|
+
},
|
|
588
|
+
createTask: function (args) { return apiCall(crewHome, "create-task", args).task; }
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ── CLI ──────────────────────────────────────────────────────────────
|
|
593
|
+
|
|
594
|
+
function main() {
|
|
595
|
+
const args = process.argv.slice(2);
|
|
596
|
+
let crewHome = null;
|
|
597
|
+
for (let i = 0; i < args.length; i++) {
|
|
598
|
+
if (args[i] === "--crew-home" && i + 1 < args.length) crewHome = args[i + 1];
|
|
599
|
+
}
|
|
600
|
+
if (!crewHome) {
|
|
601
|
+
process.stderr.write("usage: node update-watch.js --crew-home <path>\n");
|
|
602
|
+
process.exit(2);
|
|
603
|
+
}
|
|
604
|
+
run(buildRealDeps(crewHome)).then(
|
|
605
|
+
function (res) {
|
|
606
|
+
res.summary.forEach(function (line) { process.stdout.write(line + "\n"); });
|
|
607
|
+
process.exit(0);
|
|
608
|
+
},
|
|
609
|
+
function (e) {
|
|
610
|
+
// Defensive last resort: run() is built to never throw, but if it does
|
|
611
|
+
// the loop must still survive.
|
|
612
|
+
appendLog(crewHome, "error", "unexpected failure (" + firstLine(e) + ")");
|
|
613
|
+
process.stdout.write("update-watch: unexpected failure (logged)\n");
|
|
614
|
+
process.exit(0);
|
|
615
|
+
}
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
module.exports = {
|
|
620
|
+
parseSemver: parseSemver,
|
|
621
|
+
compareSemver: compareSemver,
|
|
622
|
+
channelAllows: channelAllows,
|
|
623
|
+
crewDecision: crewDecision,
|
|
624
|
+
dashboardDecision: dashboardDecision,
|
|
625
|
+
crewVersionOrdering: crewVersionOrdering,
|
|
626
|
+
fileCrewUpgrade: fileCrewUpgrade,
|
|
627
|
+
readState: readState,
|
|
628
|
+
writeState: writeState,
|
|
629
|
+
appendLog: appendLog,
|
|
630
|
+
run: run
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
if (require.main === module) main();
|