botmux 3.1.1 → 3.2.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/dist/adapters/backend/sandbox.d.ts +23 -0
- package/dist/adapters/backend/sandbox.d.ts.map +1 -1
- package/dist/adapters/backend/sandbox.js +68 -1
- package/dist/adapters/backend/sandbox.js.map +1 -1
- package/dist/adapters/cli/read-isolation.d.ts +1 -1
- package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
- package/dist/adapters/cli/read-isolation.js +15 -7
- package/dist/adapters/cli/read-isolation.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +117 -25
- package/dist/cli.js.map +1 -1
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +11 -4
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts +1 -0
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +132 -0
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/maintenance.d.ts +23 -1
- package/dist/core/maintenance.d.ts.map +1 -1
- package/dist/core/maintenance.js +63 -25
- package/dist/core/maintenance.js.map +1 -1
- package/dist/core/plugins/cli-generation.d.ts.map +1 -1
- package/dist/core/plugins/cli-generation.js +7 -0
- package/dist/core/plugins/cli-generation.js.map +1 -1
- package/dist/core/plugins/convention-scanner.d.ts +3 -3
- package/dist/core/plugins/convention-scanner.d.ts.map +1 -1
- package/dist/core/plugins/convention-scanner.js.map +1 -1
- package/dist/core/plugins/install.d.ts.map +1 -1
- package/dist/core/plugins/install.js +84 -26
- package/dist/core/plugins/install.js.map +1 -1
- package/dist/core/plugins/mcp/environment.d.ts +12 -0
- package/dist/core/plugins/mcp/environment.d.ts.map +1 -0
- package/dist/core/plugins/mcp/environment.js +17 -0
- package/dist/core/plugins/mcp/environment.js.map +1 -0
- package/dist/core/plugins/mcp/gateway-installer.d.ts.map +1 -1
- package/dist/core/plugins/mcp/gateway-installer.js +9 -3
- package/dist/core/plugins/mcp/gateway-installer.js.map +1 -1
- package/dist/core/plugins/mcp/gateway.d.ts +13 -1
- package/dist/core/plugins/mcp/gateway.d.ts.map +1 -1
- package/dist/core/plugins/mcp/gateway.js +143 -23
- package/dist/core/plugins/mcp/gateway.js.map +1 -1
- package/dist/core/plugins/mcp/host.d.ts +18 -0
- package/dist/core/plugins/mcp/host.d.ts.map +1 -0
- package/dist/core/plugins/mcp/host.js +126 -0
- package/dist/core/plugins/mcp/host.js.map +1 -0
- package/dist/core/plugins/mcp/private-store.d.ts +15 -0
- package/dist/core/plugins/mcp/private-store.d.ts.map +1 -0
- package/dist/core/plugins/mcp/private-store.js +162 -0
- package/dist/core/plugins/mcp/private-store.js.map +1 -0
- package/dist/core/plugins/mcp/session-runtime.d.ts +25 -0
- package/dist/core/plugins/mcp/session-runtime.d.ts.map +1 -0
- package/dist/core/plugins/mcp/session-runtime.js +76 -0
- package/dist/core/plugins/mcp/session-runtime.js.map +1 -0
- package/dist/core/plugins/mcp/socket-auth.d.ts +9 -0
- package/dist/core/plugins/mcp/socket-auth.d.ts.map +1 -0
- package/dist/core/plugins/mcp/socket-auth.js +94 -0
- package/dist/core/plugins/mcp/socket-auth.js.map +1 -0
- package/dist/core/plugins/paths.d.ts +3 -0
- package/dist/core/plugins/paths.d.ts.map +1 -1
- package/dist/core/plugins/paths.js +9 -0
- package/dist/core/plugins/paths.js.map +1 -1
- package/dist/core/plugins/pm2.d.ts.map +1 -1
- package/dist/core/plugins/pm2.js +3 -1
- package/dist/core/plugins/pm2.js.map +1 -1
- package/dist/core/plugins/runtime.d.ts +2 -0
- package/dist/core/plugins/runtime.d.ts.map +1 -1
- package/dist/core/plugins/runtime.js.map +1 -1
- package/dist/core/plugins/service-manager.d.ts +22 -0
- package/dist/core/plugins/service-manager.d.ts.map +1 -1
- package/dist/core/plugins/service-manager.js +189 -52
- package/dist/core/plugins/service-manager.js.map +1 -1
- package/dist/core/plugins/types.d.ts +15 -1
- package/dist/core/plugins/types.d.ts.map +1 -1
- package/dist/core/restart-report.d.ts +1 -1
- package/dist/core/restart-report.d.ts.map +1 -1
- package/dist/core/restart-report.js +5 -3
- package/dist/core/restart-report.js.map +1 -1
- package/dist/core/scheduler.d.ts +47 -0
- package/dist/core/scheduler.d.ts.map +1 -1
- package/dist/core/scheduler.js +117 -0
- package/dist/core/scheduler.js.map +1 -1
- package/dist/core/session-manager.d.ts +7 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +130 -33
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/silent-schedule-turns.d.ts +5 -0
- package/dist/core/silent-schedule-turns.d.ts.map +1 -0
- package/dist/core/silent-schedule-turns.js +47 -0
- package/dist/core/silent-schedule-turns.js.map +1 -0
- package/dist/core/types.d.ts +6 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/update-check.d.ts +16 -0
- package/dist/core/update-check.d.ts.map +1 -1
- package/dist/core/update-check.js +52 -0
- package/dist/core/update-check.js.map +1 -1
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +30 -23
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/dashboard/schedule-card-model.d.ts +3 -0
- package/dist/dashboard/schedule-card-model.d.ts.map +1 -1
- package/dist/dashboard/schedule-card-model.js +5 -0
- package/dist/dashboard/schedule-card-model.js.map +1 -1
- package/dist/dashboard/web/app.d.ts.map +1 -1
- package/dist/dashboard/web/app.js +295 -6
- package/dist/dashboard/web/app.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +92 -0
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/plugin-page.d.ts.map +1 -1
- package/dist/dashboard/web/plugin-page.js +1 -3
- package/dist/dashboard/web/plugin-page.js.map +1 -1
- package/dist/dashboard/web/schedules-page.d.ts.map +1 -1
- package/dist/dashboard/web/schedules-page.js +111 -4
- package/dist/dashboard/web/schedules-page.js.map +1 -1
- package/dist/dashboard/web/settings-page.d.ts.map +1 -1
- package/dist/dashboard/web/settings-page.js +5 -1
- package/dist/dashboard/web/settings-page.js.map +1 -1
- package/dist/dashboard/web/update-action.d.ts +16 -0
- package/dist/dashboard/web/update-action.d.ts.map +1 -0
- package/dist/dashboard/web/update-action.js +64 -0
- package/dist/dashboard/web/update-action.js.map +1 -0
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/{bot-defaults-page-KLDCZNLP.js → bot-defaults-page-4PM4T6QQ.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-FMJNOATE.js → chunk-3PMN55Z6.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-MLNI5BKE.js → chunk-AIDN2WZB.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-EZAW2EPM.js → chunk-IKCZ677K.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-KEAOTUL3.js → chunk-RXVA6XW3.js} +7 -7
- package/dist/dashboard-web/chunks/{connectors-page-O32LFAL5.js → connectors-page-2MKF4YTN.js} +1 -1
- package/dist/dashboard-web/chunks/{groups-page-PSAFIZ6M.js → groups-page-TAU2UIVS.js} +1 -1
- package/dist/dashboard-web/chunks/{insights-page-V23LLATX.js → insights-page-PJ2XVM6T.js} +1 -1
- package/dist/dashboard-web/chunks/{monitor-room-7X5AKYOF.js → monitor-room-D6445HJO.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-CBA3CQYL.js → monitoring-page-GUHMCKSO.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-RL6QSEM7.js → office-page-6K6U2MS5.js} +1 -1
- package/dist/dashboard-web/chunks/{overview-page-XQIPO6KH.js → overview-page-USDYU7U5.js} +1 -1
- package/dist/dashboard-web/chunks/plugin-page-JXC5F5YV.js +1 -0
- package/dist/dashboard-web/chunks/{roles-page-ACIWV7QS.js → roles-page-O4OUA7ZH.js} +1 -1
- package/dist/dashboard-web/chunks/schedules-page-LNIBXJSZ.js +1 -0
- package/dist/dashboard-web/chunks/{sessions-page-2WDWHSP4.js → sessions-page-B6U6TCFF.js} +1 -1
- package/dist/dashboard-web/chunks/{settings-page-6VX4OUGG.js → settings-page-PXLRFF7I.js} +2 -2
- package/dist/dashboard-web/chunks/{skills-page-WXE3UYLT.js → skills-page-M5KYNXBA.js} +1 -1
- package/dist/dashboard-web/chunks/{team-federation-page-PYTF4FLM.js → team-federation-page-GT7UFQMN.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-G6CD6O3I.js → v3-page-UYZIRJ2C.js} +1 -1
- package/dist/dashboard-web/chunks/{whiteboards-page-6SG33GP4.js → whiteboards-page-WAR77DQE.js} +1 -1
- package/dist/dashboard-web/style.css +697 -0
- package/dist/dashboard.js +404 -29
- package/dist/dashboard.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +4 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +4 -0
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/schedules-card.d.ts.map +1 -1
- package/dist/im/lark/schedules-card.js +2 -0
- package/dist/im/lark/schedules-card.js.map +1 -1
- package/dist/services/plugin-registry-store.d.ts.map +1 -1
- package/dist/services/plugin-registry-store.js +100 -18
- package/dist/services/plugin-registry-store.js.map +1 -1
- package/dist/services/restart-intent-store.d.ts +17 -2
- package/dist/services/restart-intent-store.d.ts.map +1 -1
- package/dist/services/restart-intent-store.js +110 -0
- package/dist/services/restart-intent-store.js.map +1 -1
- package/dist/services/schedule-store.d.ts +3 -1
- package/dist/services/schedule-store.d.ts.map +1 -1
- package/dist/services/schedule-store.js +5 -0
- package/dist/services/schedule-store.js.map +1 -1
- package/dist/skills/definitions.d.ts.map +1 -1
- package/dist/skills/definitions.js +3 -2
- package/dist/skills/definitions.js.map +1 -1
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/child-env.d.ts +1 -1
- package/dist/utils/child-env.d.ts.map +1 -1
- package/dist/utils/child-env.js +5 -0
- package/dist/utils/child-env.js.map +1 -1
- package/dist/utils/global-install.d.ts +2 -0
- package/dist/utils/global-install.d.ts.map +1 -1
- package/dist/utils/global-install.js +14 -0
- package/dist/utils/global-install.js.map +1 -1
- package/dist/worker.js +125 -17
- package/dist/worker.js.map +1 -1
- package/dist/workflows/hostExecutors/botmux-schedule.d.ts +3 -0
- package/dist/workflows/hostExecutors/botmux-schedule.d.ts.map +1 -1
- package/dist/workflows/hostExecutors/botmux-schedule.js +9 -0
- package/dist/workflows/hostExecutors/botmux-schedule.js.map +1 -1
- package/package.json +1 -1
- package/dist/dashboard-web/chunks/plugin-page-7FW2LPJH.js +0 -1
- package/dist/dashboard-web/chunks/schedules-page-KJDUKOJV.js +0 -1
package/dist/dashboard.js
CHANGED
|
@@ -38,12 +38,12 @@ import { dashboardSecretPath } from './core/dashboard-secret.js';
|
|
|
38
38
|
import { deleteWhiteboard, listWhiteboards, readWhiteboard, whiteboardEnabled } from './services/whiteboard-store.js';
|
|
39
39
|
import { isLocalDevInstall, botmuxVersionAt, botmuxCliEntry, botmuxInstallRoot } from './utils/install-info.js';
|
|
40
40
|
import { checkNode, detectBotmuxInstalls, resolveCurrentVersion } from './utils/install-diagnostics.js';
|
|
41
|
-
import { fetchLatestVersion, fetchReleasesSince, isNewerVersion } from './core/update-check.js';
|
|
41
|
+
import { fetchLatestVersion, fetchReleasesSince, fetchRollbackVersions, compareVersions, isCanonicalStableVersion, isNewerVersion, } from './core/update-check.js';
|
|
42
42
|
import { GITHUB_REPO } from './core/restart-report.js';
|
|
43
43
|
import { spawnDetachedRestart, globalInstallUpdateLockTarget, globalInstallUpdateCwd } from './core/maintenance.js';
|
|
44
|
-
import { detectGlobalInstallManager, formatGlobalInstallCommand, resolveGlobalInstallPlan, tryResolveGlobalInstallPlan, UnsupportedGlobalInstallError, } from './utils/global-install.js';
|
|
44
|
+
import { detectGlobalInstallManager, formatGlobalInstallCommand, resolveGlobalInstallPlan, tryResolveGlobalInstallPlan, withGlobalInstallRegistry, UnsupportedGlobalInstallError, } from './utils/global-install.js';
|
|
45
45
|
import { listCliRuntimeUpdateEntries } from './core/cli-runtime-update.js';
|
|
46
|
-
import { writeRestartIntent } from './services/restart-intent-store.js';
|
|
46
|
+
import { claimRestartLease, clearRestartIntent, clearRestartLease, hasActiveRestartLease, writeManualIntentIfAbsent, writeRestartIntent, } from './services/restart-intent-store.js';
|
|
47
47
|
import { withFileLock } from './utils/file-lock.js';
|
|
48
48
|
import { spawn } from 'node:child_process';
|
|
49
49
|
import { applySettingsWrite, defaultSettingsWriteApplierDeps, } from './dashboard/settings-write-applier.js';
|
|
@@ -187,6 +187,22 @@ function verifyDashboardBinding(port) {
|
|
|
187
187
|
mkdirSync(REGISTRY_DIR, { recursive: true });
|
|
188
188
|
const registry = new DaemonRegistry(REGISTRY_DIR);
|
|
189
189
|
const aggregator = new Aggregator();
|
|
190
|
+
/**
|
|
191
|
+
* Resolve which daemon owns a schedule row. For rows with an explicit
|
|
192
|
+
* `larkAppId`, returns that. For legacy rows (no owner stamp), falls back to
|
|
193
|
+
* the primary daemon (botIndex === 0) — the only daemon that executes legacy
|
|
194
|
+
* tasks (see scheduler.belongsToOwner). Returns undefined when the row is
|
|
195
|
+
* genuinely unknown or no daemon is online.
|
|
196
|
+
*/
|
|
197
|
+
function resolveScheduleOwner(id) {
|
|
198
|
+
const explicit = aggregator.scheduleOwnerOf(id);
|
|
199
|
+
if (explicit)
|
|
200
|
+
return explicit;
|
|
201
|
+
if (!aggregator.scheduleExists(id))
|
|
202
|
+
return undefined;
|
|
203
|
+
const primary = registry.list().find(d => d.botIndex === 0);
|
|
204
|
+
return primary?.larkAppId;
|
|
205
|
+
}
|
|
190
206
|
/**
|
|
191
207
|
* Bring a freshly-onboarded bot online without a fleet-wide restart by spawning
|
|
192
208
|
* `botmux start-bot <appId> --json` (see cli.ts:ensureBotDaemonStarted). The new
|
|
@@ -755,14 +771,37 @@ const LATEST_TTL_MS = 30 * 60_000;
|
|
|
755
771
|
const CHANGELOG_TTL_MS = 15 * 60_000;
|
|
756
772
|
const FAILURE_TTL_MS = 60_000;
|
|
757
773
|
let latestVersionCache = null;
|
|
774
|
+
let latestVersionLookupInFlight = null;
|
|
758
775
|
let changelogCache = null;
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
const
|
|
764
|
-
latestVersionCache
|
|
765
|
-
|
|
776
|
+
let rollbackVersionCache = null;
|
|
777
|
+
let rollbackVersionLookupInFlight = null;
|
|
778
|
+
async function cachedLatestVersion(force = false) {
|
|
779
|
+
const now = Date.now();
|
|
780
|
+
const ttl = latestVersionCache?.lookupOk ? LATEST_TTL_MS : FAILURE_TTL_MS;
|
|
781
|
+
if (!force && latestVersionCache && now - latestVersionCache.at < ttl)
|
|
782
|
+
return latestVersionCache;
|
|
783
|
+
// When forcing a refresh, don't piggy-back on an in-flight lookup that may
|
|
784
|
+
// have started before the user asked for a refresh — start a fresh one so
|
|
785
|
+
// the result reflects the current upstream state, not a stale query.
|
|
786
|
+
if (!force && latestVersionLookupInFlight)
|
|
787
|
+
return latestVersionLookupInFlight;
|
|
788
|
+
const lookup = (async () => {
|
|
789
|
+
const value = await fetchLatestVersion();
|
|
790
|
+
latestVersionCache = {
|
|
791
|
+
value: value ?? latestVersionCache?.value ?? null,
|
|
792
|
+
at: Date.now(),
|
|
793
|
+
lookupOk: value !== null,
|
|
794
|
+
};
|
|
795
|
+
return latestVersionCache;
|
|
796
|
+
})();
|
|
797
|
+
latestVersionLookupInFlight = lookup;
|
|
798
|
+
try {
|
|
799
|
+
return await lookup;
|
|
800
|
+
}
|
|
801
|
+
finally {
|
|
802
|
+
if (latestVersionLookupInFlight === lookup)
|
|
803
|
+
latestVersionLookupInFlight = null;
|
|
804
|
+
}
|
|
766
805
|
}
|
|
767
806
|
async function cachedChangelog(current, now = Date.now()) {
|
|
768
807
|
const ttl = changelogCache?.value.ok ? CHANGELOG_TTL_MS : FAILURE_TTL_MS;
|
|
@@ -772,6 +811,36 @@ async function cachedChangelog(current, now = Date.now()) {
|
|
|
772
811
|
changelogCache = { key: current, value, at: now };
|
|
773
812
|
return value;
|
|
774
813
|
}
|
|
814
|
+
async function cachedRollbackVersions(current, force = false) {
|
|
815
|
+
const now = Date.now();
|
|
816
|
+
const ttl = rollbackVersionCache?.value.ok ? LATEST_TTL_MS : FAILURE_TTL_MS;
|
|
817
|
+
if (!force && rollbackVersionCache?.current === current && now - rollbackVersionCache.at < ttl) {
|
|
818
|
+
return rollbackVersionCache.value;
|
|
819
|
+
}
|
|
820
|
+
if (rollbackVersionLookupInFlight?.current === current)
|
|
821
|
+
return rollbackVersionLookupInFlight.value;
|
|
822
|
+
const lookup = (async () => {
|
|
823
|
+
const result = await fetchRollbackVersions(current);
|
|
824
|
+
const previous = rollbackVersionCache?.current === current ? rollbackVersionCache.value.versions : [];
|
|
825
|
+
const value = result.ok ? result : { ok: false, versions: previous };
|
|
826
|
+
rollbackVersionCache = { current, value, at: Date.now() };
|
|
827
|
+
return value;
|
|
828
|
+
})();
|
|
829
|
+
rollbackVersionLookupInFlight = { current, value: lookup };
|
|
830
|
+
try {
|
|
831
|
+
return await lookup;
|
|
832
|
+
}
|
|
833
|
+
finally {
|
|
834
|
+
if (rollbackVersionLookupInFlight?.value === lookup)
|
|
835
|
+
rollbackVersionLookupInFlight = null;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
function currentInstalledVersion() {
|
|
839
|
+
if (!lastSuccessfulUpdatePlan)
|
|
840
|
+
return resolveCurrentVersion();
|
|
841
|
+
const version = botmuxVersionAt(lastSuccessfulUpdatePlan.activePackageRoot);
|
|
842
|
+
return version === '0.0.0' ? resolveCurrentVersion() : version;
|
|
843
|
+
}
|
|
775
844
|
/**
|
|
776
845
|
* Run the ownership-aware npm/pnpm/Bun update for the manual-update flow WITHOUT blocking
|
|
777
846
|
* the event loop (async spawn, not execSync — the dashboard must keep serving
|
|
@@ -779,7 +848,7 @@ async function cachedChangelog(current, now = Date.now()) {
|
|
|
779
848
|
* stdout/stderr on a non-zero exit, spawn error, or 3-minute timeout. Args are
|
|
780
849
|
* a fixed literal — no shell interpolation of untrusted input.
|
|
781
850
|
*/
|
|
782
|
-
function
|
|
851
|
+
function runGlobalInstall(plan) {
|
|
783
852
|
return new Promise((resolve, reject) => {
|
|
784
853
|
const child = spawn(plan.command, plan.args, {
|
|
785
854
|
cwd: globalInstallUpdateCwd(),
|
|
@@ -2278,15 +2347,16 @@ const server = createServer(async (req, res) => {
|
|
|
2278
2347
|
// unauthenticated caller (in both normal and public-read mode). The explicit
|
|
2279
2348
|
// `authed` guards on the two mutations are defense-in-depth for host actions.
|
|
2280
2349
|
if (req.method === 'GET' && url.pathname === '/api/update/status') {
|
|
2281
|
-
const current =
|
|
2282
|
-
const packageRoot = botmuxInstallRoot();
|
|
2350
|
+
const current = currentInstalledVersion();
|
|
2351
|
+
const packageRoot = lastSuccessfulUpdatePlan?.activePackageRoot ?? botmuxInstallRoot();
|
|
2283
2352
|
const installManager = detectGlobalInstallManager(packageRoot);
|
|
2284
|
-
const installPlan =
|
|
2353
|
+
const installPlan = tryResolveGlobalInstallPlan(packageRoot);
|
|
2285
2354
|
// Compare against the npm `latest` dist-tag (always stable; the update
|
|
2286
2355
|
// button installs `@latest`). isNewerVersion uses semver precedence, so a
|
|
2287
2356
|
// canary running AHEAD of the latest stable (e.g. 2.87.0-canary.0 vs
|
|
2288
2357
|
// 2.86.0) is NOT flagged behind — exactly the canary case we want.
|
|
2289
|
-
const
|
|
2358
|
+
const latestResult = await cachedLatestVersion(url.searchParams.get('refresh') === '1');
|
|
2359
|
+
const latest = latestResult.value;
|
|
2290
2360
|
const cliUpdates = listCliRuntimeUpdateEntries(config.session.dataDir).map((entry) => ({
|
|
2291
2361
|
cliId: entry.cliId,
|
|
2292
2362
|
binPath: entry.binPath,
|
|
@@ -2300,6 +2370,7 @@ const server = createServer(async (req, res) => {
|
|
|
2300
2370
|
return jsonRes(res, 200, {
|
|
2301
2371
|
current,
|
|
2302
2372
|
latest,
|
|
2373
|
+
versionLookupOk: latestResult.lookupOk,
|
|
2303
2374
|
behind: !!latest && isNewerVersion(latest, current),
|
|
2304
2375
|
cliBehind: cliUpdates.some((entry) => entry.updateAvailable),
|
|
2305
2376
|
cliUpdates,
|
|
@@ -2311,8 +2382,13 @@ const server = createServer(async (req, res) => {
|
|
|
2311
2382
|
installs: detectBotmuxInstalls(),
|
|
2312
2383
|
});
|
|
2313
2384
|
}
|
|
2385
|
+
if (req.method === 'GET' && url.pathname === '/api/update/versions') {
|
|
2386
|
+
const current = currentInstalledVersion();
|
|
2387
|
+
const result = await cachedRollbackVersions(current, url.searchParams.get('refresh') === '1');
|
|
2388
|
+
return jsonRes(res, 200, { current, ok: result.ok, versions: result.versions });
|
|
2389
|
+
}
|
|
2314
2390
|
if (req.method === 'GET' && url.pathname === '/api/update/changelog') {
|
|
2315
|
-
const current =
|
|
2391
|
+
const current = currentInstalledVersion();
|
|
2316
2392
|
const result = await cachedChangelog(current);
|
|
2317
2393
|
return jsonRes(res, 200, {
|
|
2318
2394
|
current,
|
|
@@ -2329,7 +2405,8 @@ const server = createServer(async (req, res) => {
|
|
|
2329
2405
|
return jsonRes(res, 400, { ok: false, error: 'local_dev_no_update' });
|
|
2330
2406
|
let installPlan;
|
|
2331
2407
|
try {
|
|
2332
|
-
|
|
2408
|
+
const packageRoot = lastSuccessfulUpdatePlan?.activePackageRoot ?? botmuxInstallRoot();
|
|
2409
|
+
installPlan = resolveGlobalInstallPlan(packageRoot);
|
|
2333
2410
|
}
|
|
2334
2411
|
catch (error) {
|
|
2335
2412
|
if (error instanceof UnsupportedGlobalInstallError) {
|
|
@@ -2347,17 +2424,23 @@ const server = createServer(async (req, res) => {
|
|
|
2347
2424
|
if (updateInFlight)
|
|
2348
2425
|
return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
|
|
2349
2426
|
updateInFlight = true;
|
|
2350
|
-
|
|
2427
|
+
let oldVersion = '';
|
|
2351
2428
|
// Acquire the shared cross-process lock so a scheduled maintenance
|
|
2352
2429
|
// auto-update (running in the bot-0 daemon) can't update the same global
|
|
2353
2430
|
// install concurrently. `acquired` distinguishes "lock held by
|
|
2354
2431
|
// maintenance" (409) from "the package manager failed" (500). Short wait:
|
|
2355
2432
|
// don't block the request on a full in-progress install — report busy fast.
|
|
2356
2433
|
let acquired = false;
|
|
2434
|
+
let blockedByRestart = false;
|
|
2357
2435
|
try {
|
|
2358
2436
|
await withFileLock(globalInstallUpdateLockTarget(), async () => {
|
|
2359
2437
|
acquired = true;
|
|
2360
|
-
|
|
2438
|
+
if (hasActiveRestartLease()) {
|
|
2439
|
+
blockedByRestart = true;
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
oldVersion = botmuxVersionAt(installPlan.activePackageRoot);
|
|
2443
|
+
await runGlobalInstall(installPlan);
|
|
2361
2444
|
}, { maxWaitMs: 2_000 });
|
|
2362
2445
|
}
|
|
2363
2446
|
catch (e) {
|
|
@@ -2368,6 +2451,8 @@ const server = createServer(async (req, res) => {
|
|
|
2368
2451
|
finally {
|
|
2369
2452
|
updateInFlight = false;
|
|
2370
2453
|
}
|
|
2454
|
+
if (blockedByRestart)
|
|
2455
|
+
return jsonRes(res, 409, { ok: false, error: 'restart_in_flight' });
|
|
2371
2456
|
const newVersion = botmuxVersionAt(installPlan.activePackageRoot);
|
|
2372
2457
|
lastSuccessfulUpdatePlan = installPlan;
|
|
2373
2458
|
return jsonRes(res, 200, {
|
|
@@ -2378,9 +2463,179 @@ const server = createServer(async (req, res) => {
|
|
|
2378
2463
|
manager: installPlan.manager,
|
|
2379
2464
|
});
|
|
2380
2465
|
}
|
|
2466
|
+
if (req.method === 'POST' && url.pathname === '/api/update/rollback') {
|
|
2467
|
+
if (!authed)
|
|
2468
|
+
return jsonRes(res, 401, { ok: false, error: 'unauthorized' });
|
|
2469
|
+
if (isLocalDevInstall())
|
|
2470
|
+
return jsonRes(res, 400, { ok: false, error: 'local_dev_no_update' });
|
|
2471
|
+
let targetVersion = '';
|
|
2472
|
+
try {
|
|
2473
|
+
const parsed = await readJsonBody(req);
|
|
2474
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
2475
|
+
return jsonRes(res, 400, { ok: false, error: 'invalid_version' });
|
|
2476
|
+
}
|
|
2477
|
+
const body = parsed;
|
|
2478
|
+
if (Object.keys(body).length !== 1 || typeof body.version !== 'string' || !isCanonicalStableVersion(body.version)) {
|
|
2479
|
+
return jsonRes(res, 400, { ok: false, error: 'invalid_version' });
|
|
2480
|
+
}
|
|
2481
|
+
targetVersion = body.version;
|
|
2482
|
+
}
|
|
2483
|
+
catch {
|
|
2484
|
+
return jsonRes(res, 400, { ok: false, error: 'invalid_json' });
|
|
2485
|
+
}
|
|
2486
|
+
const rollback = await cachedRollbackVersions(currentInstalledVersion());
|
|
2487
|
+
if (!rollback.ok)
|
|
2488
|
+
return jsonRes(res, 503, { ok: false, error: 'versions_unavailable' });
|
|
2489
|
+
if (!rollback.versions.some(entry => entry.version === targetVersion)) {
|
|
2490
|
+
return jsonRes(res, 400, { ok: false, error: 'not_rollback_target' });
|
|
2491
|
+
}
|
|
2492
|
+
let installPlan;
|
|
2493
|
+
try {
|
|
2494
|
+
const packageRoot = lastSuccessfulUpdatePlan?.activePackageRoot ?? botmuxInstallRoot();
|
|
2495
|
+
installPlan = withGlobalInstallRegistry(resolveGlobalInstallPlan(packageRoot, process.platform, `botmux@${targetVersion}`));
|
|
2496
|
+
}
|
|
2497
|
+
catch (error) {
|
|
2498
|
+
if (error instanceof UnsupportedGlobalInstallError) {
|
|
2499
|
+
return jsonRes(res, 400, {
|
|
2500
|
+
ok: false,
|
|
2501
|
+
error: 'unsupported_install_method',
|
|
2502
|
+
manager: error.manager,
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
throw error;
|
|
2506
|
+
}
|
|
2507
|
+
const node = checkNode();
|
|
2508
|
+
if (!node.ok)
|
|
2509
|
+
return jsonRes(res, 400, { ok: false, error: 'node_too_old', node });
|
|
2510
|
+
if (updateInFlight)
|
|
2511
|
+
return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
|
|
2512
|
+
updateInFlight = true;
|
|
2513
|
+
let acquired = false;
|
|
2514
|
+
let blockedByRestart = false;
|
|
2515
|
+
let invalidRollbackTarget = false;
|
|
2516
|
+
let installedVersionMismatch = '';
|
|
2517
|
+
let restartIntentError = '';
|
|
2518
|
+
let leaseId = null;
|
|
2519
|
+
let oldVersion = '';
|
|
2520
|
+
try {
|
|
2521
|
+
await withFileLock(globalInstallUpdateLockTarget(), async () => {
|
|
2522
|
+
acquired = true;
|
|
2523
|
+
if (hasActiveRestartLease()) {
|
|
2524
|
+
blockedByRestart = true;
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
oldVersion = botmuxVersionAt(installPlan.activePackageRoot);
|
|
2528
|
+
if (compareVersions(targetVersion, oldVersion) >= 0) {
|
|
2529
|
+
invalidRollbackTarget = true;
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
await runGlobalInstall(installPlan);
|
|
2533
|
+
const newVersion = botmuxVersionAt(installPlan.activePackageRoot);
|
|
2534
|
+
lastSuccessfulUpdatePlan = installPlan;
|
|
2535
|
+
if (newVersion !== targetVersion) {
|
|
2536
|
+
installedVersionMismatch = newVersion;
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
leaseId = claimRestartLease();
|
|
2540
|
+
if (!leaseId) {
|
|
2541
|
+
blockedByRestart = true;
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
try {
|
|
2545
|
+
writeRestartIntent({
|
|
2546
|
+
kind: 'rollback',
|
|
2547
|
+
oldVersion,
|
|
2548
|
+
newVersion,
|
|
2549
|
+
at: new Date().toISOString(),
|
|
2550
|
+
});
|
|
2551
|
+
}
|
|
2552
|
+
catch (error) {
|
|
2553
|
+
restartIntentError = error instanceof Error ? error.message : String(error);
|
|
2554
|
+
clearRestartLease(leaseId);
|
|
2555
|
+
leaseId = null;
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
// Keep the install lock through restart handoff: maintenance cannot
|
|
2559
|
+
// race in between the downgrade and the detached restart driver.
|
|
2560
|
+
await new Promise((resolveLaunch) => {
|
|
2561
|
+
let launched = false;
|
|
2562
|
+
const launch = () => {
|
|
2563
|
+
if (launched)
|
|
2564
|
+
return;
|
|
2565
|
+
launched = true;
|
|
2566
|
+
try {
|
|
2567
|
+
const child = spawnDetachedRestart('dashboard', installPlan.activePackageRoot, leaseId);
|
|
2568
|
+
if (!child.pid)
|
|
2569
|
+
throw new Error('restart driver did not start');
|
|
2570
|
+
}
|
|
2571
|
+
catch (error) {
|
|
2572
|
+
clearRestartLease(leaseId);
|
|
2573
|
+
clearRestartIntent();
|
|
2574
|
+
logger.error(`[dashboard] rollback restart launch failed: ${error instanceof Error ? error.message : error}`);
|
|
2575
|
+
}
|
|
2576
|
+
finally {
|
|
2577
|
+
resolveLaunch();
|
|
2578
|
+
}
|
|
2579
|
+
};
|
|
2580
|
+
res.once('finish', launch);
|
|
2581
|
+
res.once('close', launch);
|
|
2582
|
+
try {
|
|
2583
|
+
jsonRes(res, 202, {
|
|
2584
|
+
ok: true,
|
|
2585
|
+
oldVersion,
|
|
2586
|
+
newVersion,
|
|
2587
|
+
changed: true,
|
|
2588
|
+
manager: installPlan.manager,
|
|
2589
|
+
operation: 'rollback',
|
|
2590
|
+
});
|
|
2591
|
+
}
|
|
2592
|
+
finally {
|
|
2593
|
+
if (res.destroyed || res.writableFinished)
|
|
2594
|
+
launch();
|
|
2595
|
+
}
|
|
2596
|
+
});
|
|
2597
|
+
}, { maxWaitMs: 2_000 });
|
|
2598
|
+
if (blockedByRestart)
|
|
2599
|
+
return jsonRes(res, 409, { ok: false, error: 'restart_in_flight' });
|
|
2600
|
+
if (invalidRollbackTarget)
|
|
2601
|
+
return jsonRes(res, 409, { ok: false, error: 'not_rollback_target' });
|
|
2602
|
+
if (installedVersionMismatch) {
|
|
2603
|
+
return jsonRes(res, 500, {
|
|
2604
|
+
ok: false,
|
|
2605
|
+
error: 'install_version_mismatch',
|
|
2606
|
+
expectedVersion: targetVersion,
|
|
2607
|
+
actualVersion: installedVersionMismatch,
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
if (restartIntentError) {
|
|
2611
|
+
return jsonRes(res, 500, { ok: false, error: 'restart_intent_failed', detail: restartIntentError });
|
|
2612
|
+
}
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
2615
|
+
catch (error) {
|
|
2616
|
+
if (leaseId)
|
|
2617
|
+
clearRestartLease(leaseId);
|
|
2618
|
+
if (!acquired)
|
|
2619
|
+
return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
|
|
2620
|
+
if (!res.headersSent) {
|
|
2621
|
+
return jsonRes(res, 500, {
|
|
2622
|
+
ok: false,
|
|
2623
|
+
error: 'install_failed',
|
|
2624
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
logger.error(`[dashboard] rollback failed after response: ${error instanceof Error ? error.message : error}`);
|
|
2628
|
+
return;
|
|
2629
|
+
}
|
|
2630
|
+
finally {
|
|
2631
|
+
updateInFlight = false;
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2381
2634
|
if (req.method === 'POST' && url.pathname === '/api/update/restart') {
|
|
2382
2635
|
if (!authed)
|
|
2383
2636
|
return jsonRes(res, 401, { ok: false, error: 'unauthorized' });
|
|
2637
|
+
if (updateInFlight)
|
|
2638
|
+
return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
|
|
2384
2639
|
let body = {};
|
|
2385
2640
|
try {
|
|
2386
2641
|
const parsed = await readJsonBody(req);
|
|
@@ -2389,17 +2644,74 @@ const server = createServer(async (req, res) => {
|
|
|
2389
2644
|
}
|
|
2390
2645
|
catch { /* empty / bad body → plain restart */ }
|
|
2391
2646
|
const upd = body.update && typeof body.update === 'object' ? body.update : null;
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2647
|
+
let acquired = false;
|
|
2648
|
+
let leaseId = null;
|
|
2649
|
+
let activePackageRoot;
|
|
2650
|
+
let shouldLaunch = false;
|
|
2651
|
+
try {
|
|
2652
|
+
await withFileLock(globalInstallUpdateLockTarget(), async () => {
|
|
2653
|
+
acquired = true;
|
|
2654
|
+
const claimed = claimRestartLease();
|
|
2655
|
+
if (!claimed) {
|
|
2656
|
+
jsonRes(res, 202, { ok: true, alreadyScheduled: true });
|
|
2657
|
+
return;
|
|
2658
|
+
}
|
|
2659
|
+
leaseId = claimed;
|
|
2660
|
+
try {
|
|
2661
|
+
if (upd && typeof upd.oldVersion === 'string' && typeof upd.newVersion === 'string' && upd.oldVersion !== upd.newVersion) {
|
|
2662
|
+
writeRestartIntent({ kind: 'update', oldVersion: upd.oldVersion, newVersion: upd.newVersion, at: new Date().toISOString() });
|
|
2663
|
+
}
|
|
2664
|
+
else {
|
|
2665
|
+
writeManualIntentIfAbsent();
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
catch (error) {
|
|
2669
|
+
clearRestartLease(leaseId);
|
|
2670
|
+
leaseId = null;
|
|
2671
|
+
jsonRes(res, 500, {
|
|
2672
|
+
ok: false,
|
|
2673
|
+
error: 'restart_intent_failed',
|
|
2674
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
2675
|
+
});
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2678
|
+
activePackageRoot = (lastSuccessfulUpdatePlan ?? tryResolveGlobalInstallPlan())?.activePackageRoot;
|
|
2679
|
+
// Send acknowledgement while holding the lock, then release immediately.
|
|
2680
|
+
// The lease itself prevents concurrent restarts — no need to hold the
|
|
2681
|
+
// lock across the network round-trip waiting for res.finish.
|
|
2682
|
+
jsonRes(res, 202, { ok: true });
|
|
2683
|
+
shouldLaunch = true;
|
|
2684
|
+
}, { maxWaitMs: 2_000 });
|
|
2685
|
+
}
|
|
2686
|
+
catch (error) {
|
|
2687
|
+
if (!acquired)
|
|
2688
|
+
return jsonRes(res, 409, { ok: false, error: 'update_in_flight' });
|
|
2689
|
+
throw error;
|
|
2690
|
+
}
|
|
2691
|
+
// Spawn the detached driver after the lock is released. The lease guards
|
|
2692
|
+
// against double-restart; if launch fails we clear the lease so a retry
|
|
2693
|
+
// can succeed.
|
|
2694
|
+
if (shouldLaunch && leaseId) {
|
|
2695
|
+
const launch = () => {
|
|
2696
|
+
try {
|
|
2697
|
+
const child = spawnDetachedRestart('dashboard', activePackageRoot, leaseId);
|
|
2698
|
+
if (!child.pid)
|
|
2699
|
+
throw new Error('restart driver did not start');
|
|
2700
|
+
}
|
|
2701
|
+
catch (error) {
|
|
2702
|
+
clearRestartLease(leaseId);
|
|
2703
|
+
logger.error(`[dashboard] restart launch failed: ${error instanceof Error ? error.message : error}`);
|
|
2704
|
+
}
|
|
2705
|
+
};
|
|
2706
|
+
if (res.destroyed || res.writableFinished) {
|
|
2707
|
+
launch();
|
|
2708
|
+
}
|
|
2709
|
+
else {
|
|
2710
|
+
res.once('finish', launch);
|
|
2711
|
+
res.once('close', launch);
|
|
2398
2712
|
}
|
|
2399
|
-
catch { /* breadcrumb is best-effort */ }
|
|
2400
2713
|
}
|
|
2401
|
-
|
|
2402
|
-
return jsonRes(res, 200, { ok: true });
|
|
2714
|
+
return;
|
|
2403
2715
|
}
|
|
2404
2716
|
if (req.method === 'GET' && url.pathname === '/api/skills') {
|
|
2405
2717
|
return jsonRes(res, 200, dashboardSkillsPayload());
|
|
@@ -2907,7 +3219,7 @@ const server = createServer(async (req, res) => {
|
|
|
2907
3219
|
if (req.method === 'POST' && (m = url.pathname.match(/^\/api\/schedules\/([^/]+)\/(run|pause|resume|delivery)$/))) {
|
|
2908
3220
|
const id = decodeURIComponent(m[1]);
|
|
2909
3221
|
const op = m[2];
|
|
2910
|
-
const owner =
|
|
3222
|
+
const owner = resolveScheduleOwner(id);
|
|
2911
3223
|
if (!owner)
|
|
2912
3224
|
return jsonRes(res, 404, { ok: false, error: 'unknown_schedule' });
|
|
2913
3225
|
const upstream = await proxyToDaemon(owner, `/api/schedules/${id}/${op}`, { method: 'POST' });
|
|
@@ -2915,6 +3227,69 @@ const server = createServer(async (req, res) => {
|
|
|
2915
3227
|
res.end(await upstream.text());
|
|
2916
3228
|
return;
|
|
2917
3229
|
}
|
|
3230
|
+
// Create a new scheduled task. Body must include `larkAppId` to select
|
|
3231
|
+
// which bot/daemon owns the task (multi-bot dashboards cannot guess).
|
|
3232
|
+
if (req.method === 'POST' && url.pathname === '/api/schedules') {
|
|
3233
|
+
let body;
|
|
3234
|
+
try {
|
|
3235
|
+
body = await readJsonBody(req);
|
|
3236
|
+
}
|
|
3237
|
+
catch {
|
|
3238
|
+
return jsonRes(res, 400, { ok: false, error: 'bad_json' });
|
|
3239
|
+
}
|
|
3240
|
+
if (body === null || typeof body !== 'object') {
|
|
3241
|
+
return jsonRes(res, 400, { ok: false, error: 'body_must_be_object' });
|
|
3242
|
+
}
|
|
3243
|
+
const b = body;
|
|
3244
|
+
const larkAppId = typeof b.larkAppId === 'string' ? b.larkAppId : '';
|
|
3245
|
+
if (!larkAppId)
|
|
3246
|
+
return jsonRes(res, 400, { ok: false, error: 'larkAppId_required' });
|
|
3247
|
+
const upstream = await proxyToDaemon(larkAppId, '/api/schedules', {
|
|
3248
|
+
method: 'POST',
|
|
3249
|
+
headers: { 'content-type': 'application/json' },
|
|
3250
|
+
body: JSON.stringify(body),
|
|
3251
|
+
});
|
|
3252
|
+
res.writeHead(upstream.status, { 'content-type': 'application/json' });
|
|
3253
|
+
res.end(await upstream.text());
|
|
3254
|
+
return;
|
|
3255
|
+
}
|
|
3256
|
+
// Update an existing task (PATCH) or delete it (DELETE). Both route to
|
|
3257
|
+
// the daemon that owns the task. Legacy rows (no larkAppId) fall back to
|
|
3258
|
+
// the primary daemon (botIndex === 0) so they remain editable.
|
|
3259
|
+
if (req.method === 'PATCH' && (m = url.pathname.match(/^\/api\/schedules\/([^/]+)$/))) {
|
|
3260
|
+
const id = decodeURIComponent(m[1]);
|
|
3261
|
+
const owner = resolveScheduleOwner(id);
|
|
3262
|
+
if (!owner)
|
|
3263
|
+
return jsonRes(res, 404, { ok: false, error: 'unknown_schedule' });
|
|
3264
|
+
let body;
|
|
3265
|
+
try {
|
|
3266
|
+
body = await readJsonBody(req);
|
|
3267
|
+
}
|
|
3268
|
+
catch {
|
|
3269
|
+
return jsonRes(res, 400, { ok: false, error: 'bad_json' });
|
|
3270
|
+
}
|
|
3271
|
+
if (body === null || typeof body !== 'object') {
|
|
3272
|
+
return jsonRes(res, 400, { ok: false, error: 'body_must_be_object' });
|
|
3273
|
+
}
|
|
3274
|
+
const upstream = await proxyToDaemon(owner, `/api/schedules/${id}`, {
|
|
3275
|
+
method: 'PATCH',
|
|
3276
|
+
headers: { 'content-type': 'application/json' },
|
|
3277
|
+
body: JSON.stringify(body),
|
|
3278
|
+
});
|
|
3279
|
+
res.writeHead(upstream.status, { 'content-type': 'application/json' });
|
|
3280
|
+
res.end(await upstream.text());
|
|
3281
|
+
return;
|
|
3282
|
+
}
|
|
3283
|
+
if (req.method === 'DELETE' && (m = url.pathname.match(/^\/api\/schedules\/([^/]+)$/))) {
|
|
3284
|
+
const id = decodeURIComponent(m[1]);
|
|
3285
|
+
const owner = resolveScheduleOwner(id);
|
|
3286
|
+
if (!owner)
|
|
3287
|
+
return jsonRes(res, 404, { ok: false, error: 'unknown_schedule' });
|
|
3288
|
+
const upstream = await proxyToDaemon(owner, `/api/schedules/${id}`, { method: 'DELETE' });
|
|
3289
|
+
res.writeHead(upstream.status, { 'content-type': 'application/json' });
|
|
3290
|
+
res.end(await upstream.text());
|
|
3291
|
+
return;
|
|
3292
|
+
}
|
|
2918
3293
|
// v3 workflow runs. Reads project directly from disk; cancel resolves the
|
|
2919
3294
|
// immutable run owner and proxies to that daemon (the dashboard never
|
|
2920
3295
|
// writes the v3 journal itself).
|