forked from openclaw/openclaw
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpost-core-plugin-convergence.ts
More file actions
232 lines (221 loc) Β· 9.25 KB
/
Copy pathpost-core-plugin-convergence.ts
File metadata and controls
232 lines (221 loc) Β· 9.25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
import { repairMissingConfiguredPluginInstalls } from "../../commands/doctor/shared/missing-configured-plugin-install.js";
import { UPDATE_POST_CORE_CONVERGENCE_ENV } from "../../commands/doctor/shared/update-phase.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
import { resolveDefaultPluginNpmDir } from "../../plugins/install-paths.js";
import { listManagedPluginNpmRoots } from "../../plugins/npm-project-roots.js";
import { relinkOpenClawPeerDependenciesInManagedNpmRoot } from "../../plugins/plugin-peer-link.js";
import { pruneStaleLocalBundledPluginInstallRecords } from "../../plugins/stale-local-bundled-plugin-install-records.js";
import {
resolveTrustedSourceLinkedOfficialClawHubSpec,
resolveTrustedSourceLinkedOfficialNpmSpec,
} from "../../plugins/update.js";
import { VERSION } from "../../version.js";
import {
runPluginPayloadSmokeCheck,
type PluginPayloadSmokeFailure,
} from "./plugin-payload-validation.js";
export type PostCoreConvergenceWarning = {
pluginId?: string;
reason: string;
message: string;
guidance: string[];
};
export type PostCoreConvergenceResult = {
changes: string[];
warnings: PostCoreConvergenceWarning[];
errored: boolean;
smokeFailures: PluginPayloadSmokeFailure[];
/**
* Final install-record map after convergence: this is the
* `baselineInstallRecords` the caller passed in (their in-memory state
* including any sync/npm mutations that happened earlier in the
* post-core flow) WITH convergence's repair mutations layered on top.
* Convergence has already persisted this map to the installed-plugin
* index, so the caller's subsequent commit MUST seed its write from
* these records β otherwise the stale pre-convergence snapshot will
* overwrite both the sync/npm mutations AND the fresh repairs.
*/
installRecords: Record<string, PluginInstallRecord>;
};
const REPAIR_GUIDANCE = "Run `openclaw doctor --fix` to retry plugin repair.";
const inspectGuidance = (pluginId: string) =>
`Run \`openclaw plugins inspect ${pluginId} --runtime --json\` for details.`;
async function repairManagedNpmOpenClawPeerLinks(params: {
env: NodeJS.ProcessEnv;
}): Promise<{ changes: string[]; warnings: PostCoreConvergenceWarning[] }> {
try {
const npmRoots = await listManagedPluginNpmRoots(resolveDefaultPluginNpmDir(params.env));
const results = await Promise.all(
npmRoots.map((npmRoot) =>
relinkOpenClawPeerDependenciesInManagedNpmRoot({
npmRoot,
logger: {},
}),
),
);
const repaired = results.reduce((total, result) => total + result.repaired, 0);
return {
changes:
repaired > 0
? [`Repaired OpenClaw host peer link(s) for ${repaired} managed npm plugin package(s).`]
: [],
warnings: [],
};
} catch (err) {
const message = `Failed to repair managed npm OpenClaw host peer links: ${err instanceof Error ? err.message : String(err)}`;
return {
changes: [],
warnings: [
{
reason: message,
message,
guidance: [REPAIR_GUIDANCE],
},
],
};
}
}
/**
* Mandatory post-core convergence pass. Runs AFTER the core package files
* are swapped and the in-update doctor pass has already returned, but BEFORE
* the gateway is restarted. Failures here must block the restart so we
* never restart with a configured plugin whose payload is unloadable.
*/
export async function runPostCorePluginConvergence(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
/**
* Optional in-memory install records from earlier post-core steps (e.g.
* `syncPluginsForUpdateChannel`, `updateNpmInstalledPlugins`) whose
* mutations have not been persisted to the installed-plugin index yet.
* When provided, repair layers its mutations on top of these records
* instead of reading the stale pre-update disk snapshot, and the merged
* map is what gets persisted and returned via `installRecords`.
*/
baselineInstallRecords?: Record<string, PluginInstallRecord>;
}): Promise<PostCoreConvergenceResult> {
const env: NodeJS.ProcessEnv = {
...params.env,
OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION,
[UPDATE_POST_CORE_CONVERGENCE_ENV]: "1",
};
const prunedBaseline = params.baselineInstallRecords
? pruneStaleLocalBundledPluginInstallRecords({
installRecords: params.baselineInstallRecords,
env,
})
: null;
const repair = await repairMissingConfiguredPluginInstalls({
cfg: params.cfg,
env,
...(prunedBaseline ? { baselineRecords: prunedBaseline.records } : {}),
});
const warnings: PostCoreConvergenceWarning[] = repair.warnings.map((message) => ({
reason: message,
message,
guidance: [REPAIR_GUIDANCE],
}));
const peerLinkRepair = await repairManagedNpmOpenClawPeerLinks({ env });
warnings.push(...peerLinkRepair.warnings);
const records: Record<string, PluginInstallRecord> = repair.records;
// Filter the smoke-check input to active records ONLY: configured /
// enabled plugins, plus trusted-source-linked official sync targets
// (mirroring the existing `collectMissingPluginInstallPayloads` policy
// at update-command.ts:~218 with `skipDisabledPlugins: true`). Without
// this filter, a stale install record for a disabled or no-longer-
// configured plugin whose payload was deleted on disk would block the
// entire update β even though the gateway will never load that plugin.
const smokeRecords = filterRecordsToActive({ cfg: params.cfg, records });
const smoke = await runPluginPayloadSmokeCheck({ records: smokeRecords, env });
for (const failure of smoke.failures) {
warnings.push({
pluginId: failure.pluginId,
reason: `${failure.reason}: ${failure.detail}`,
message: `Plugin "${failure.pluginId}" failed post-core payload smoke check (${failure.reason}): ${failure.detail}`,
guidance: [REPAIR_GUIDANCE, inspectGuidance(failure.pluginId)],
});
}
return {
changes: [
...(prunedBaseline?.stale.map(
(record) => `Removed stale local bundled plugin install record "${record.pluginId}".`,
) ?? []),
...repair.changes,
...peerLinkRepair.changes,
],
warnings,
errored: warnings.length > 0,
smokeFailures: smoke.failures,
installRecords: records,
};
}
/**
* Drop install records that the gateway would never activate: disabled
* plugin entries, plugins listed in `plugins.deny`, etc. Records that
* resolve as a trusted-source-linked official install (npm or ClawHub)
* are retained even when the entry is disabled, mirroring the existing
* `collectMissingPluginInstallPayloads({ skipDisabledPlugins: true,
* syncOfficialPluginInstalls: true })` policy at
* `update-command.ts:~218`. We do NOT collapse to the configured plugin
* id set here β that would over-filter and miss e.g. providers/runtimes
* that are enabled implicitly via auth profiles or model refs. Effective
* enable state is the right precision boundary.
*/
export function filterRecordsToActive(params: {
cfg: OpenClawConfig;
records: Record<string, PluginInstallRecord>;
}): Record<string, PluginInstallRecord> {
const normalizedPluginConfig = normalizePluginsConfig(params.cfg.plugins);
const filtered: Record<string, PluginInstallRecord> = {};
for (const [pluginId, record] of Object.entries(params.records)) {
if (!record || typeof record !== "object") {
continue;
}
const enableState = resolveEffectiveEnableState({
id: pluginId,
origin: "global",
config: normalizedPluginConfig,
rootConfig: params.cfg,
});
if (enableState.enabled) {
filtered[pluginId] = record;
continue;
}
// Even when disabled, retain trusted-source-linked official installs
// because the existing post-update sync path treats them as
// authoritative regardless of the entry's enable flag.
const officialNpm = resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record });
const officialClawHub = resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record });
if (officialNpm || officialClawHub) {
filtered[pluginId] = record;
}
}
return filtered;
}
/**
* Pure helper used by `updatePluginsAfterCoreUpdate` to fold a convergence
* result into the existing `PluginUpdateOutcome[]` / warning shape that the
* post-core update result carries.
*
* Returns:
* - `outcomes` to append to `pluginUpdateOutcomes`. Only convergence
* warnings that name a `pluginId` produce per-plugin error outcomes; the
* rest are surfaced via `warnings`.
* - `errored` boolean that callers translate into `status: "error"`.
*/
export function convergenceWarningsToOutcomes(convergence: PostCoreConvergenceResult): {
warnings: PostCoreConvergenceWarning[];
outcomes: Array<{ pluginId: string; status: "error"; message: string }>;
errored: boolean;
} {
const outcomes = convergence.warnings
.filter((w): w is PostCoreConvergenceWarning & { pluginId: string } => Boolean(w.pluginId))
.map((w) => ({ pluginId: w.pluginId, status: "error" as const, message: w.message }));
return {
warnings: convergence.warnings,
outcomes,
errored: convergence.errored,
};
}