@evref-bl/plexus-core 0.1.0-alpha.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/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +164 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +62 -0
- package/dist/config.js.map +1 -0
- package/dist/imageRescue.d.ts +136 -0
- package/dist/imageRescue.d.ts.map +1 -0
- package/dist/imageRescue.js +686 -0
- package/dist/imageRescue.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/pathStyle.d.ts +10 -0
- package/dist/pathStyle.d.ts.map +1 -0
- package/dist/pathStyle.js +33 -0
- package/dist/pathStyle.js.map +1 -0
- package/dist/pharoLauncherMcpClient.d.ts +12 -0
- package/dist/pharoLauncherMcpClient.d.ts.map +1 -0
- package/dist/pharoLauncherMcpClient.js +74 -0
- package/dist/pharoLauncherMcpClient.js.map +1 -0
- package/dist/pharoMcpHealth.d.ts +22 -0
- package/dist/pharoMcpHealth.d.ts.map +1 -0
- package/dist/pharoMcpHealth.js +80 -0
- package/dist/pharoMcpHealth.js.map +1 -0
- package/dist/projectClose.d.ts +28 -0
- package/dist/projectClose.d.ts.map +1 -0
- package/dist/projectClose.js +93 -0
- package/dist/projectClose.js.map +1 -0
- package/dist/projectConfig.d.ts +44 -0
- package/dist/projectConfig.d.ts.map +1 -0
- package/dist/projectConfig.js +208 -0
- package/dist/projectConfig.js.map +1 -0
- package/dist/projectLifecycle.d.ts +115 -0
- package/dist/projectLifecycle.d.ts.map +1 -0
- package/dist/projectLifecycle.js +495 -0
- package/dist/projectLifecycle.js.map +1 -0
- package/dist/projectOpen.d.ts +49 -0
- package/dist/projectOpen.d.ts.map +1 -0
- package/dist/projectOpen.js +218 -0
- package/dist/projectOpen.js.map +1 -0
- package/dist/projectStartupScript.d.ts +56 -0
- package/dist/projectStartupScript.d.ts.map +1 -0
- package/dist/projectStartupScript.js +150 -0
- package/dist/projectStartupScript.js.map +1 -0
- package/dist/projectState.d.ts +128 -0
- package/dist/projectState.d.ts.map +1 -0
- package/dist/projectState.js +192 -0
- package/dist/projectState.js.map +1 -0
- package/dist/scopedPharoLauncherServer.d.ts +87 -0
- package/dist/scopedPharoLauncherServer.d.ts.map +1 -0
- package/dist/scopedPharoLauncherServer.js +186 -0
- package/dist/scopedPharoLauncherServer.js.map +1 -0
- package/dist/scopedProjectContext.d.ts +93 -0
- package/dist/scopedProjectContext.d.ts.map +1 -0
- package/dist/scopedProjectContext.js +181 -0
- package/dist/scopedProjectContext.js.map +1 -0
- package/dist/server.d.ts +42 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +133 -0
- package/dist/server.js.map +1 -0
- package/dist/target.d.ts +19 -0
- package/dist/target.d.ts.map +1 -0
- package/dist/target.js +2 -0
- package/dist/target.js.map +1 -0
- package/dist/workspaceMcpConfig.d.ts +42 -0
- package/dist/workspaceMcpConfig.d.ts.map +1 -0
- package/dist/workspaceMcpConfig.js +79 -0
- package/dist/workspaceMcpConfig.js.map +1 -0
- package/package.json +37 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadProjectConfig } from "./projectConfig.js";
|
|
4
|
+
import { createStdioPharoLauncherMcpClient, } from "./pharoLauncherMcpClient.js";
|
|
5
|
+
import { HttpPharoMcpHealthClient, } from "./pharoMcpHealth.js";
|
|
6
|
+
import { defaultProjectPortRange, defaultWorkspaceId, loadProjectState, projectStatePathForConfig, sanitizeRuntimeId, saveProjectState, } from "./projectState.js";
|
|
7
|
+
import { writeImageStartupScript } from "./projectStartupScript.js";
|
|
8
|
+
const defaultPoll = {
|
|
9
|
+
intervalMs: 500,
|
|
10
|
+
processTimeoutMs: 30_000,
|
|
11
|
+
healthTimeoutMs: 5 * 60_000,
|
|
12
|
+
};
|
|
13
|
+
export class ImageRescueError extends Error {
|
|
14
|
+
result;
|
|
15
|
+
constructor(message, result) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.result = result;
|
|
18
|
+
this.name = "ImageRescueError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function defaultSleep(durationMs) {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
23
|
+
}
|
|
24
|
+
function errorMessage(error) {
|
|
25
|
+
return error instanceof Error ? error.message : String(error);
|
|
26
|
+
}
|
|
27
|
+
function isObject(value) {
|
|
28
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
function launcherResultData(result) {
|
|
31
|
+
if (isObject(result) && "data" in result) {
|
|
32
|
+
const commandResult = result;
|
|
33
|
+
return commandResult.ok === false ? undefined : commandResult.data;
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
function assertLauncherOk(result, toolName) {
|
|
38
|
+
if (isObject(result) && result.ok === false) {
|
|
39
|
+
throw new Error(`${toolName} returned ok: false`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function parseToolContent(value) {
|
|
43
|
+
if (!isObject(value)) {
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
const content = value.content;
|
|
47
|
+
if (!Array.isArray(content)) {
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
const textContent = content.find((item) => isObject(item) && item.type === "text" && typeof item.text === "string");
|
|
51
|
+
if (!textContent) {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(textContent.text);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return textContent.text;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function resultData(value) {
|
|
62
|
+
const parsed = parseToolContent(value);
|
|
63
|
+
if (isObject(parsed) && "data" in parsed) {
|
|
64
|
+
return parsed.data;
|
|
65
|
+
}
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
function nowStamp(now) {
|
|
69
|
+
return now().toISOString().replaceAll(/[:.]/g, "-");
|
|
70
|
+
}
|
|
71
|
+
function defaultTargetImageId(sourceImageId) {
|
|
72
|
+
return sanitizeRuntimeId(`${sourceImageId}-rescue`);
|
|
73
|
+
}
|
|
74
|
+
function defaultTargetImageName(sourceImageName, now) {
|
|
75
|
+
return `${sourceImageName}-rescue-${nowStamp(now).slice(0, 19)}`;
|
|
76
|
+
}
|
|
77
|
+
function processMatchesImage(process, imageName) {
|
|
78
|
+
return (process.imageName === imageName ||
|
|
79
|
+
path.basename(process.imagePath ?? "", ".image") === imageName ||
|
|
80
|
+
process.commandLine.includes(`${imageName}.image`) ||
|
|
81
|
+
process.commandLine.includes(imageName));
|
|
82
|
+
}
|
|
83
|
+
async function pollUntil(timeoutMs, intervalMs, sleep, producer) {
|
|
84
|
+
const startedAt = Date.now();
|
|
85
|
+
while (Date.now() - startedAt <= timeoutMs) {
|
|
86
|
+
const result = await producer();
|
|
87
|
+
if (result !== undefined) {
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
await sleep(intervalMs);
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
async function pollProcessForImage(client, imageName, timeoutMs, intervalMs, sleep) {
|
|
95
|
+
return pollUntil(timeoutMs, intervalMs, sleep, async () => {
|
|
96
|
+
const result = await client.callTool("pharo_launcher_process_list");
|
|
97
|
+
const processes = launcherResultData(result) ?? [];
|
|
98
|
+
return processes.find((process) => processMatchesImage(process, imageName));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
async function launchImage(client, healthClient, imageState, startupScriptPath, poll, sleep) {
|
|
102
|
+
const launchResult = await client.callTool("pharo_launcher_image_launch", {
|
|
103
|
+
imageName: imageState.imageName,
|
|
104
|
+
detached: true,
|
|
105
|
+
script: startupScriptPath,
|
|
106
|
+
});
|
|
107
|
+
assertLauncherOk(launchResult, "pharo_launcher_image_launch");
|
|
108
|
+
const process = await pollProcessForImage(client, imageState.imageName, poll.processTimeoutMs, poll.intervalMs, sleep);
|
|
109
|
+
if (!process) {
|
|
110
|
+
throw new Error(`Timed out waiting for PharoLauncher process for image ${imageState.imageName}`);
|
|
111
|
+
}
|
|
112
|
+
const healthy = await pollUntil(poll.healthTimeoutMs, poll.intervalMs, sleep, async () => ((await healthClient.check(imageState.assignedPort)) ? true : undefined));
|
|
113
|
+
if (!healthy) {
|
|
114
|
+
throw new Error(`Timed out waiting for Pharo MCP health on port ${imageState.assignedPort}`);
|
|
115
|
+
}
|
|
116
|
+
return process.pid;
|
|
117
|
+
}
|
|
118
|
+
function resolveWorkspaceId(projectRoot, workspaceId) {
|
|
119
|
+
return workspaceId ? sanitizeRuntimeId(workspaceId) : defaultWorkspaceId(projectRoot);
|
|
120
|
+
}
|
|
121
|
+
function findImageState(state, imageId) {
|
|
122
|
+
const image = state.images.find((candidate) => candidate.id === imageId);
|
|
123
|
+
if (!image) {
|
|
124
|
+
throw new ImageRescueError(`Project state does not contain image id: ${imageId}`);
|
|
125
|
+
}
|
|
126
|
+
return image;
|
|
127
|
+
}
|
|
128
|
+
function findImageConfig(configImages, imageId) {
|
|
129
|
+
const image = configImages.find((candidate) => candidate.id === imageId);
|
|
130
|
+
if (!image) {
|
|
131
|
+
throw new ImageRescueError(`Project config does not contain image id: ${imageId}`);
|
|
132
|
+
}
|
|
133
|
+
return image;
|
|
134
|
+
}
|
|
135
|
+
function allocatePort(state, requestedPort, range) {
|
|
136
|
+
const usedPorts = new Set(state.images.map((image) => image.assignedPort));
|
|
137
|
+
if (requestedPort !== undefined) {
|
|
138
|
+
if (usedPorts.has(requestedPort)) {
|
|
139
|
+
throw new ImageRescueError(`Requested target MCP port is already used: ${requestedPort}`);
|
|
140
|
+
}
|
|
141
|
+
return requestedPort;
|
|
142
|
+
}
|
|
143
|
+
for (let port = range.start; port <= range.end; port += 1) {
|
|
144
|
+
if (!usedPorts.has(port)) {
|
|
145
|
+
return port;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
throw new ImageRescueError(`No available port in range ${range.start}-${range.end}`);
|
|
149
|
+
}
|
|
150
|
+
function launcherImagesDirectory(configReport) {
|
|
151
|
+
return (configReport.profile?.imagesDir?.path ??
|
|
152
|
+
(configReport.launcherDir?.path
|
|
153
|
+
? path.join(configReport.launcherDir.path, "images")
|
|
154
|
+
: undefined));
|
|
155
|
+
}
|
|
156
|
+
function resolveImagePaths(imageName, launcherImage, configReport) {
|
|
157
|
+
const rawImagePath = launcherImage?.imagePath;
|
|
158
|
+
const imagesDirectory = launcherImagesDirectory(configReport);
|
|
159
|
+
const imagePath = rawImagePath
|
|
160
|
+
? path.isAbsolute(rawImagePath)
|
|
161
|
+
? rawImagePath
|
|
162
|
+
: imagesDirectory
|
|
163
|
+
? path.resolve(imagesDirectory, rawImagePath)
|
|
164
|
+
: path.resolve(rawImagePath)
|
|
165
|
+
: imagesDirectory
|
|
166
|
+
? path.join(imagesDirectory, imageName, `${imageName}.image`)
|
|
167
|
+
: undefined;
|
|
168
|
+
const imageDirectoryPath = imagePath ? path.dirname(imagePath) : undefined;
|
|
169
|
+
const changesPath = imagePath
|
|
170
|
+
? imagePath.replace(/\.image$/i, ".changes")
|
|
171
|
+
: undefined;
|
|
172
|
+
const localDirectoryPath = imageDirectoryPath
|
|
173
|
+
? path.join(imageDirectoryPath, "pharo-local")
|
|
174
|
+
: undefined;
|
|
175
|
+
const ombuDirectoryPath = localDirectoryPath
|
|
176
|
+
? path.join(localDirectoryPath, "ombu-sessions")
|
|
177
|
+
: undefined;
|
|
178
|
+
return {
|
|
179
|
+
...(imagePath ? { imagePath } : {}),
|
|
180
|
+
...(imageDirectoryPath ? { imageDirectoryPath } : {}),
|
|
181
|
+
...(changesPath ? { changesPath } : {}),
|
|
182
|
+
...(localDirectoryPath ? { localDirectoryPath } : {}),
|
|
183
|
+
...(ombuDirectoryPath ? { ombuDirectoryPath } : {}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
async function launcherImageInfo(client, imageName) {
|
|
187
|
+
const result = await client.callTool("pharo_launcher_image_info", {
|
|
188
|
+
imageName,
|
|
189
|
+
format: "ston",
|
|
190
|
+
});
|
|
191
|
+
return launcherResultData(result);
|
|
192
|
+
}
|
|
193
|
+
async function launcherConfigReport(client) {
|
|
194
|
+
const result = await client.callTool("pharo_launcher_config");
|
|
195
|
+
return launcherResultData(result) ?? {};
|
|
196
|
+
}
|
|
197
|
+
async function snapshotRepositories(image, imageMcpClient, capturedAt) {
|
|
198
|
+
if (!imageMcpClient || image.status !== "running") {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const result = await imageMcpClient.callTool(image, "find_repositories", {
|
|
203
|
+
limit: 1000,
|
|
204
|
+
});
|
|
205
|
+
const repositories = extractObjects(resultData(result));
|
|
206
|
+
return {
|
|
207
|
+
capturedAt,
|
|
208
|
+
status: "captured",
|
|
209
|
+
repositories,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
return {
|
|
214
|
+
capturedAt,
|
|
215
|
+
status: "unavailable",
|
|
216
|
+
repositories: [],
|
|
217
|
+
error: errorMessage(error),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function extractObjects(value) {
|
|
222
|
+
if (Array.isArray(value)) {
|
|
223
|
+
return value.filter(isObject);
|
|
224
|
+
}
|
|
225
|
+
if (isObject(value)) {
|
|
226
|
+
for (const key of ["repositories", "items", "entries", "data"]) {
|
|
227
|
+
const nested = value[key];
|
|
228
|
+
if (Array.isArray(nested)) {
|
|
229
|
+
return nested.filter(isObject);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
function listOmbuFiles(directoryPath) {
|
|
236
|
+
if (!directoryPath || !fs.existsSync(directoryPath)) {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
return fs
|
|
240
|
+
.readdirSync(directoryPath, { withFileTypes: true })
|
|
241
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".ombu"))
|
|
242
|
+
.map((entry) => {
|
|
243
|
+
const filePath = path.join(directoryPath, entry.name);
|
|
244
|
+
const stat = fs.statSync(filePath);
|
|
245
|
+
return {
|
|
246
|
+
path: filePath,
|
|
247
|
+
mtimeMs: stat.mtimeMs,
|
|
248
|
+
size: stat.size,
|
|
249
|
+
};
|
|
250
|
+
})
|
|
251
|
+
.sort((a, b) => (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0));
|
|
252
|
+
}
|
|
253
|
+
function selectionArguments(selection) {
|
|
254
|
+
if (!selection) {
|
|
255
|
+
return {};
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
...(selection.indexes ? { indexes: selection.indexes } : {}),
|
|
259
|
+
...(selection.entryReferences
|
|
260
|
+
? { entryReferences: selection.entryReferences }
|
|
261
|
+
: {}),
|
|
262
|
+
...(selection.startIndex !== undefined ? { startIndex: selection.startIndex } : {}),
|
|
263
|
+
...(selection.endIndex !== undefined ? { endIndex: selection.endIndex } : {}),
|
|
264
|
+
...(selection.latestCount !== undefined ? { latestCount: selection.latestCount } : {}),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function extractIndexesFromEntries(value) {
|
|
268
|
+
const entries = extractObjects(value);
|
|
269
|
+
const indexes = entries
|
|
270
|
+
.map((entry) => entry.index)
|
|
271
|
+
.filter((index) => Number.isInteger(index));
|
|
272
|
+
return indexes;
|
|
273
|
+
}
|
|
274
|
+
function excludedIndexes(exclude) {
|
|
275
|
+
if (exclude?.latestCount !== undefined) {
|
|
276
|
+
throw new ImageRescueError("exclude.latestCount cannot be inverted safely yet; use indexes or ranges");
|
|
277
|
+
}
|
|
278
|
+
const indexes = new Set(exclude?.indexes ?? []);
|
|
279
|
+
if (exclude?.startIndex !== undefined && exclude.endIndex !== undefined) {
|
|
280
|
+
for (let index = exclude.startIndex; index <= exclude.endIndex; index += 1) {
|
|
281
|
+
indexes.add(index);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return indexes;
|
|
285
|
+
}
|
|
286
|
+
function selectedIndexes(selection, availableIndexes) {
|
|
287
|
+
if (!selection) {
|
|
288
|
+
return new Set(availableIndexes);
|
|
289
|
+
}
|
|
290
|
+
if (selection.entryReferences && selection.entryReferences.length > 0) {
|
|
291
|
+
throw new ImageRescueError("selection.entryReferences cannot be combined with exclude yet; use indexes or ranges");
|
|
292
|
+
}
|
|
293
|
+
if (selection.latestCount !== undefined) {
|
|
294
|
+
throw new ImageRescueError("selection.latestCount cannot be combined with exclude yet; use indexes or ranges");
|
|
295
|
+
}
|
|
296
|
+
const indexes = new Set(selection.indexes ?? availableIndexes);
|
|
297
|
+
if (selection.startIndex !== undefined || selection.endIndex !== undefined) {
|
|
298
|
+
const startIndex = selection.startIndex ?? Math.min(...availableIndexes);
|
|
299
|
+
const endIndex = selection.endIndex ?? Math.max(...availableIndexes);
|
|
300
|
+
for (const index of [...indexes]) {
|
|
301
|
+
if (index < startIndex || index > endIndex) {
|
|
302
|
+
indexes.delete(index);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return indexes;
|
|
307
|
+
}
|
|
308
|
+
async function resolveSelectionWithExclusions(imageMcpClient, targetImage, historyFilePath, selection, exclude, codeChangesOnly) {
|
|
309
|
+
if (!exclude) {
|
|
310
|
+
return selection;
|
|
311
|
+
}
|
|
312
|
+
if (exclude.entryReferences && exclude.entryReferences.length > 0) {
|
|
313
|
+
throw new ImageRescueError("exclude.entryReferences cannot be inverted safely yet; use indexes or ranges");
|
|
314
|
+
}
|
|
315
|
+
const listResult = await imageMcpClient.callTool(targetImage, "manage-change-history", {
|
|
316
|
+
operation: "listEntries",
|
|
317
|
+
historyFilePath,
|
|
318
|
+
codeChangesOnly,
|
|
319
|
+
});
|
|
320
|
+
const availableIndexes = extractIndexesFromEntries(resultData(listResult));
|
|
321
|
+
if (availableIndexes.length === 0) {
|
|
322
|
+
throw new ImageRescueError("Could not read entry indexes while applying exclusions");
|
|
323
|
+
}
|
|
324
|
+
const includeIndexes = selectedIndexes(selection, availableIndexes);
|
|
325
|
+
const excludes = excludedIndexes(exclude);
|
|
326
|
+
return {
|
|
327
|
+
indexes: [...includeIndexes].filter((index) => !excludes.has(index)),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
function repositoryName(repository) {
|
|
331
|
+
const value = repository.name ?? repository.repositoryName;
|
|
332
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
333
|
+
}
|
|
334
|
+
function repositoryLocation(repository) {
|
|
335
|
+
const value = repository.location ?? repository.directory;
|
|
336
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
337
|
+
}
|
|
338
|
+
function repositorySubdirectory(repository) {
|
|
339
|
+
const value = repository.subdirectory;
|
|
340
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
341
|
+
}
|
|
342
|
+
function repositoryPackageNames(repository) {
|
|
343
|
+
const value = repository.packageNames ?? repository.packages;
|
|
344
|
+
return Array.isArray(value)
|
|
345
|
+
? value.filter((item) => typeof item === "string")
|
|
346
|
+
: undefined;
|
|
347
|
+
}
|
|
348
|
+
function defaultRepositoryActions(snapshot) {
|
|
349
|
+
if (!snapshot || snapshot.status !== "captured") {
|
|
350
|
+
return [];
|
|
351
|
+
}
|
|
352
|
+
return snapshot.repositories.flatMap((repository) => {
|
|
353
|
+
const name = repositoryName(repository);
|
|
354
|
+
const location = repositoryLocation(repository);
|
|
355
|
+
if (!name || !location) {
|
|
356
|
+
return [];
|
|
357
|
+
}
|
|
358
|
+
return [
|
|
359
|
+
{
|
|
360
|
+
label: `Register repository ${name}`,
|
|
361
|
+
toolName: "edit_repository",
|
|
362
|
+
arguments: {
|
|
363
|
+
operation: "create",
|
|
364
|
+
name,
|
|
365
|
+
location,
|
|
366
|
+
...(repositorySubdirectory(repository)
|
|
367
|
+
? { subdirectory: repositorySubdirectory(repository) }
|
|
368
|
+
: {}),
|
|
369
|
+
...(repositoryPackageNames(repository)
|
|
370
|
+
? { packageNames: repositoryPackageNames(repository) }
|
|
371
|
+
: {}),
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
];
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
async function runRepositoryActions(imageMcpClient, targetImage, actions, confirm) {
|
|
378
|
+
const results = [];
|
|
379
|
+
for (const action of actions) {
|
|
380
|
+
const toolName = action.toolName ?? "load_repository";
|
|
381
|
+
const label = action.label ?? toolName;
|
|
382
|
+
if (!confirm) {
|
|
383
|
+
results.push({
|
|
384
|
+
label,
|
|
385
|
+
toolName,
|
|
386
|
+
arguments: action.arguments,
|
|
387
|
+
status: "planned",
|
|
388
|
+
});
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (!imageMcpClient || !targetImage) {
|
|
392
|
+
results.push({
|
|
393
|
+
label,
|
|
394
|
+
toolName,
|
|
395
|
+
arguments: action.arguments,
|
|
396
|
+
status: "skipped",
|
|
397
|
+
error: "No target image MCP client is available",
|
|
398
|
+
});
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const result = await imageMcpClient.callTool(targetImage, toolName, action.arguments);
|
|
403
|
+
results.push({
|
|
404
|
+
label,
|
|
405
|
+
toolName,
|
|
406
|
+
arguments: action.arguments,
|
|
407
|
+
status: "applied",
|
|
408
|
+
result: resultData(result),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
results.push({
|
|
413
|
+
label,
|
|
414
|
+
toolName,
|
|
415
|
+
arguments: action.arguments,
|
|
416
|
+
status: "failed",
|
|
417
|
+
error: errorMessage(error),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return results;
|
|
422
|
+
}
|
|
423
|
+
function selectedHistoryFile(historyFilePath, historyFiles) {
|
|
424
|
+
return historyFilePath ?? historyFiles[0]?.path;
|
|
425
|
+
}
|
|
426
|
+
async function listHistoryFilesThroughImage(imageMcpClient, image, directoryPath, includeEntryCounts) {
|
|
427
|
+
if (!imageMcpClient || !image || image.status !== "running" || !directoryPath) {
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
const result = await imageMcpClient.callTool(image, "manage-change-history", {
|
|
431
|
+
operation: "listFiles",
|
|
432
|
+
directoryPath,
|
|
433
|
+
includeEntryCounts,
|
|
434
|
+
});
|
|
435
|
+
return resultData(result);
|
|
436
|
+
}
|
|
437
|
+
async function applyHistoryEntries(imageMcpClient, targetImage, historyFilePath, selection, exclude, codeChangesOnly, confirm) {
|
|
438
|
+
if (!historyFilePath) {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
let resolvedSelection = selection;
|
|
442
|
+
if (imageMcpClient && targetImage) {
|
|
443
|
+
resolvedSelection = await resolveSelectionWithExclusions(imageMcpClient, targetImage, historyFilePath, selection, exclude, codeChangesOnly);
|
|
444
|
+
}
|
|
445
|
+
const argumentsValue = {
|
|
446
|
+
operation: "applyEntries",
|
|
447
|
+
historyFilePath,
|
|
448
|
+
codeChangesOnly,
|
|
449
|
+
confirm,
|
|
450
|
+
...selectionArguments(resolvedSelection),
|
|
451
|
+
};
|
|
452
|
+
if (!confirm) {
|
|
453
|
+
return {
|
|
454
|
+
historyFilePath,
|
|
455
|
+
arguments: argumentsValue,
|
|
456
|
+
status: "planned",
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
if (!imageMcpClient || !targetImage) {
|
|
460
|
+
throw new ImageRescueError("A running target image is required to apply history entries");
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
const result = await imageMcpClient.callTool(targetImage, "manage-change-history", argumentsValue);
|
|
464
|
+
return {
|
|
465
|
+
historyFilePath,
|
|
466
|
+
arguments: argumentsValue,
|
|
467
|
+
status: "applied",
|
|
468
|
+
result: resultData(result),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
return {
|
|
473
|
+
historyFilePath,
|
|
474
|
+
arguments: argumentsValue,
|
|
475
|
+
status: "failed",
|
|
476
|
+
error: errorMessage(error),
|
|
477
|
+
failureClass: "apply-time-breaker",
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function updateImageMetadata(image, snapshot) {
|
|
482
|
+
image.imagePath = snapshot.paths.imagePath;
|
|
483
|
+
image.imageDirectoryPath = snapshot.paths.imageDirectoryPath;
|
|
484
|
+
image.changesPath = snapshot.paths.changesPath;
|
|
485
|
+
image.localDirectoryPath = snapshot.paths.localDirectoryPath;
|
|
486
|
+
image.ombuDirectoryPath = snapshot.paths.ombuDirectoryPath;
|
|
487
|
+
image.vmId = snapshot.launcherImage?.vmId;
|
|
488
|
+
image.pharoVersion = snapshot.launcherImage?.pharoVersion;
|
|
489
|
+
image.originTemplate = snapshot.launcherImage?.originTemplate;
|
|
490
|
+
image.rescueSnapshot = snapshot;
|
|
491
|
+
}
|
|
492
|
+
function targetConfigFromSource(sourceConfig, targetImageId, targetImageName, targetPort) {
|
|
493
|
+
return {
|
|
494
|
+
...sourceConfig,
|
|
495
|
+
id: targetImageId,
|
|
496
|
+
imageName: targetImageName,
|
|
497
|
+
active: true,
|
|
498
|
+
mcp: {
|
|
499
|
+
...sourceConfig.mcp,
|
|
500
|
+
port: targetPort,
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
async function createTargetImage(client, targetImageName, templateName, templateCategory) {
|
|
505
|
+
const result = await client.callTool("pharo_launcher_image_create", {
|
|
506
|
+
newImageName: targetImageName,
|
|
507
|
+
templateName,
|
|
508
|
+
...(templateCategory ? { templateCategory } : {}),
|
|
509
|
+
noLaunch: true,
|
|
510
|
+
});
|
|
511
|
+
assertLauncherOk(result, "pharo_launcher_image_create");
|
|
512
|
+
}
|
|
513
|
+
export async function rescueImage(options) {
|
|
514
|
+
const projectRoot = path.resolve(options.projectRoot);
|
|
515
|
+
const config = loadProjectConfig(projectRoot);
|
|
516
|
+
const workspaceId = resolveWorkspaceId(projectRoot, options.workspaceId);
|
|
517
|
+
const statePath = projectStatePathForConfig({
|
|
518
|
+
projectRoot,
|
|
519
|
+
config,
|
|
520
|
+
workspaceId,
|
|
521
|
+
stateRoot: options.stateRoot,
|
|
522
|
+
});
|
|
523
|
+
const state = loadProjectState(statePath);
|
|
524
|
+
if (!state) {
|
|
525
|
+
throw new ImageRescueError(`No PLexus runtime state found at ${statePath}`);
|
|
526
|
+
}
|
|
527
|
+
const now = options.now ?? (() => new Date());
|
|
528
|
+
const capturedAt = now().toISOString();
|
|
529
|
+
const sourceImage = findImageState(state, options.sourceImageId);
|
|
530
|
+
const sourceConfig = findImageConfig(config.images, options.sourceImageId);
|
|
531
|
+
const launcherClient = options.pharoLauncherMcpClient ??
|
|
532
|
+
(await createStdioPharoLauncherMcpClient());
|
|
533
|
+
const ownsLauncherClient = !options.pharoLauncherMcpClient;
|
|
534
|
+
const warnings = [];
|
|
535
|
+
try {
|
|
536
|
+
const [configReport, launcherImage] = await Promise.all([
|
|
537
|
+
launcherConfigReport(launcherClient),
|
|
538
|
+
launcherImageInfo(launcherClient, sourceImage.imageName),
|
|
539
|
+
]);
|
|
540
|
+
const paths = resolveImagePaths(sourceImage.imageName, launcherImage, configReport);
|
|
541
|
+
const repositorySnapshot = (await snapshotRepositories(sourceImage, options.imageMcpClient, capturedAt)) ?? sourceImage.rescueSnapshot?.repositories;
|
|
542
|
+
const sourceSnapshot = {
|
|
543
|
+
capturedAt,
|
|
544
|
+
...(launcherImage ? { launcherImage } : {}),
|
|
545
|
+
paths,
|
|
546
|
+
...(repositorySnapshot ? { repositories: repositorySnapshot } : {}),
|
|
547
|
+
};
|
|
548
|
+
const historyDirectoryPath = options.sourceHistoryDirectoryPath ?? paths.ombuDirectoryPath;
|
|
549
|
+
const historyFiles = listOmbuFiles(historyDirectoryPath);
|
|
550
|
+
const selectedHistoryFilePath = selectedHistoryFile(options.historyFilePath, historyFiles);
|
|
551
|
+
const targetImageId = options.targetImageId ?? defaultTargetImageId(options.sourceImageId);
|
|
552
|
+
let targetImage = state.images.find((image) => image.id === targetImageId);
|
|
553
|
+
let historyListing;
|
|
554
|
+
if (options.operation === "plan" && options.includeEntryCounts) {
|
|
555
|
+
const historyReader = targetImage?.status === "running" ? targetImage : sourceImage;
|
|
556
|
+
try {
|
|
557
|
+
historyListing = await listHistoryFilesThroughImage(options.imageMcpClient, historyReader, historyDirectoryPath, options.includeEntryCounts);
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
warnings.push(`Could not list history files through Pharo: ${errorMessage(error)}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if ((options.operation === "snapshotSource" || options.operation === "prepareTarget") &&
|
|
564
|
+
repositorySnapshot?.status === "unavailable") {
|
|
565
|
+
warnings.push(`Repository snapshot is unavailable: ${repositorySnapshot.error ?? "unknown error"}`);
|
|
566
|
+
}
|
|
567
|
+
if (options.operation === "snapshotSource") {
|
|
568
|
+
updateImageMetadata(sourceImage, sourceSnapshot);
|
|
569
|
+
state.updatedAt = capturedAt;
|
|
570
|
+
saveProjectState(statePath, state);
|
|
571
|
+
return {
|
|
572
|
+
ok: true,
|
|
573
|
+
operation: options.operation,
|
|
574
|
+
projectRoot,
|
|
575
|
+
statePath,
|
|
576
|
+
state,
|
|
577
|
+
sourceImage,
|
|
578
|
+
sourceSnapshot,
|
|
579
|
+
historyDirectoryPath,
|
|
580
|
+
historyFiles,
|
|
581
|
+
historyListing,
|
|
582
|
+
selectedHistoryFilePath,
|
|
583
|
+
warnings,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
if (options.operation === "prepareTarget") {
|
|
587
|
+
if (targetImage) {
|
|
588
|
+
throw new ImageRescueError(`Target image id already exists: ${targetImageId}`);
|
|
589
|
+
}
|
|
590
|
+
const targetImageName = options.targetImageName ??
|
|
591
|
+
defaultTargetImageName(sourceImage.imageName, now);
|
|
592
|
+
const templateName = options.targetTemplateName ?? launcherImage?.originTemplate?.name;
|
|
593
|
+
if (!templateName) {
|
|
594
|
+
throw new ImageRescueError("targetTemplateName is required because the source image origin template is unknown");
|
|
595
|
+
}
|
|
596
|
+
await createTargetImage(launcherClient, targetImageName, templateName, options.targetTemplateCategory);
|
|
597
|
+
const assignedPort = allocatePort(state, options.targetMcpPort, options.portRange ?? defaultProjectPortRange);
|
|
598
|
+
targetImage = {
|
|
599
|
+
id: targetImageId,
|
|
600
|
+
imageName: targetImageName,
|
|
601
|
+
assignedPort,
|
|
602
|
+
status: "starting",
|
|
603
|
+
};
|
|
604
|
+
state.images.push(targetImage);
|
|
605
|
+
updateImageMetadata(sourceImage, sourceSnapshot);
|
|
606
|
+
state.updatedAt = capturedAt;
|
|
607
|
+
saveProjectState(statePath, state);
|
|
608
|
+
const targetConfig = targetConfigFromSource(sourceConfig, targetImageId, targetImageName, assignedPort);
|
|
609
|
+
const startupScript = writeImageStartupScript({
|
|
610
|
+
projectRoot,
|
|
611
|
+
projectId: config.kanban.projectId,
|
|
612
|
+
workspaceId,
|
|
613
|
+
stateRoot: options.stateRoot,
|
|
614
|
+
imageConfig: targetConfig,
|
|
615
|
+
imageState: targetImage,
|
|
616
|
+
});
|
|
617
|
+
const poll = {
|
|
618
|
+
...defaultPoll,
|
|
619
|
+
...options.poll,
|
|
620
|
+
};
|
|
621
|
+
try {
|
|
622
|
+
const pid = await launchImage(launcherClient, options.healthClient ?? new HttpPharoMcpHealthClient(), targetImage, startupScript.filePath, poll, options.sleep ?? defaultSleep);
|
|
623
|
+
targetImage.pid = pid;
|
|
624
|
+
targetImage.status = "running";
|
|
625
|
+
state.updatedAt = now().toISOString();
|
|
626
|
+
saveProjectState(statePath, state);
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
targetImage.status = "failed";
|
|
630
|
+
state.updatedAt = now().toISOString();
|
|
631
|
+
saveProjectState(statePath, state);
|
|
632
|
+
throw error;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (options.operation === "applyPlan") {
|
|
636
|
+
if (!targetImage) {
|
|
637
|
+
throw new ImageRescueError(`Target image id is not in runtime state: ${targetImageId}`);
|
|
638
|
+
}
|
|
639
|
+
const actionSource = options.repositoryActions ??
|
|
640
|
+
((options.loadRepositories ?? true)
|
|
641
|
+
? defaultRepositoryActions(repositorySnapshot)
|
|
642
|
+
: []);
|
|
643
|
+
const repositoryResults = await runRepositoryActions(options.imageMcpClient, targetImage, actionSource, options.confirm === true);
|
|
644
|
+
const changeResult = await applyHistoryEntries(options.imageMcpClient, targetImage, selectedHistoryFilePath, options.selection, options.exclude, options.codeChangesOnly ?? true, options.confirm === true);
|
|
645
|
+
return {
|
|
646
|
+
ok: repositoryResults.every((repository) => repository.status !== "failed") &&
|
|
647
|
+
changeResult?.status !== "failed",
|
|
648
|
+
operation: options.operation,
|
|
649
|
+
projectRoot,
|
|
650
|
+
statePath,
|
|
651
|
+
state,
|
|
652
|
+
sourceImage,
|
|
653
|
+
targetImage,
|
|
654
|
+
sourceSnapshot,
|
|
655
|
+
historyDirectoryPath,
|
|
656
|
+
historyFiles,
|
|
657
|
+
historyListing,
|
|
658
|
+
selectedHistoryFilePath,
|
|
659
|
+
repositoryResults,
|
|
660
|
+
changeResult,
|
|
661
|
+
warnings,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
ok: true,
|
|
666
|
+
operation: options.operation,
|
|
667
|
+
projectRoot,
|
|
668
|
+
statePath,
|
|
669
|
+
state,
|
|
670
|
+
sourceImage,
|
|
671
|
+
targetImage,
|
|
672
|
+
sourceSnapshot,
|
|
673
|
+
historyDirectoryPath,
|
|
674
|
+
historyFiles,
|
|
675
|
+
historyListing,
|
|
676
|
+
selectedHistoryFilePath,
|
|
677
|
+
warnings,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
finally {
|
|
681
|
+
if (ownsLauncherClient) {
|
|
682
|
+
await launcherClient.close?.();
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
//# sourceMappingURL=imageRescue.js.map
|