constructor-research-mcp 0.0.0-stage → 0.3.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/INSTALL.md +145 -0
- package/LICENSE +21 -0
- package/README.md +320 -2
- package/dist/api.d.ts +66 -0
- package/dist/api.js +280 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.js +127 -0
- package/dist/deployment.d.ts +94 -0
- package/dist/deployment.js +1233 -0
- package/dist/files.d.ts +122 -0
- package/dist/files.js +822 -0
- package/dist/location.d.ts +17 -0
- package/dist/location.js +60 -0
- package/dist/login.d.ts +2 -0
- package/dist/login.js +25 -0
- package/dist/logs.d.ts +59 -0
- package/dist/logs.js +330 -0
- package/dist/runtime.d.ts +23 -0
- package/dist/runtime.js +238 -0
- package/dist/schemas.d.ts +232 -0
- package/dist/schemas.js +277 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +351 -0
- package/dist/session.d.ts +21 -0
- package/dist/session.js +108 -0
- package/dist/set-token.d.ts +2 -0
- package/dist/set-token.js +48 -0
- package/dist/signin.d.ts +19 -0
- package/dist/signin.js +209 -0
- package/dist/store.d.ts +85 -0
- package/dist/store.js +150 -0
- package/dist/wait.d.ts +26 -0
- package/dist/wait.js +241 -0
- package/examples/claude-hooks.json +16 -0
- package/examples/claude.mcp.json +8 -0
- package/examples/codex-hooks.json +16 -0
- package/examples/codex.toml +6 -0
- package/examples/deploy-app.json +39 -0
- package/examples/opencode-plugin.js +43 -0
- package/examples/opencode-v2.json +11 -0
- package/examples/opencode.json +9 -0
- package/package.json +60 -4
|
@@ -0,0 +1,1233 @@
|
|
|
1
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { ToolError, segment as e } from "./api.js";
|
|
4
|
+
import { deployInput, updateInput, buildInput, listInput, lifecycleInput, getInput, } from "./schemas.js";
|
|
5
|
+
const NOTICE = "Start requested; application readiness is unverified. Initial small deployments usually take 1–2 minutes; updates often take tens of seconds. These are estimates.";
|
|
6
|
+
export const terminal = new Set([
|
|
7
|
+
"started",
|
|
8
|
+
"stopped",
|
|
9
|
+
"built",
|
|
10
|
+
"failed",
|
|
11
|
+
"needs_reconciliation",
|
|
12
|
+
"confirm_required",
|
|
13
|
+
"superseded",
|
|
14
|
+
]);
|
|
15
|
+
/** What to check when a public URL does not answer, ordered by how often it was the cause. */
|
|
16
|
+
function silentHint(silent) {
|
|
17
|
+
if (!silent?.length)
|
|
18
|
+
return "Usually the app is not listening on 0.0.0.0 on the service port, or it crashed on start. get_logs with this deployment_id shows what the services printed since they started.";
|
|
19
|
+
return `${silent.join(", ")} printed nothing since starting: the container most likely exits before its first line and keeps restarting. Check, in this order: (1) the ENTRYPOINT/CMD script is not executable (files uploaded to a desk lose the executable bit; add RUN chmod 0755 <script>); (2) ENTRYPOINT/CMD names a path that does not exist in the image; (3) nginx has "listen [::]" and the cluster has no IPv6; (4) the app listens on 127.0.0.1 or another port. Fix the Dockerfile, sync it and run update_app for these services.`;
|
|
20
|
+
}
|
|
21
|
+
function bad(message) {
|
|
22
|
+
throw new ToolError("invalid_input", message);
|
|
23
|
+
}
|
|
24
|
+
function one(rows, message) {
|
|
25
|
+
if (rows.length > 1)
|
|
26
|
+
bad(message);
|
|
27
|
+
return rows[0];
|
|
28
|
+
}
|
|
29
|
+
function internal(project, app, name, port) {
|
|
30
|
+
return `svc-${name}.app-${project.slice(0, 4)}${app.slice(0, 4)}.svc.cluster.local:${port}`;
|
|
31
|
+
}
|
|
32
|
+
function publicService(s, project, app) {
|
|
33
|
+
return {
|
|
34
|
+
id: s.id,
|
|
35
|
+
name: s.name,
|
|
36
|
+
image_id: s.image_id,
|
|
37
|
+
status: s.status,
|
|
38
|
+
ports: s.ports,
|
|
39
|
+
volumes: s.volumes,
|
|
40
|
+
environment_variable_names: (s.environment_variables ?? []).map((x) => x.name),
|
|
41
|
+
public_url: s.ports?.some((p) => p.expose_to_internet)
|
|
42
|
+
? (s.link ?? `https://${s.id}.constructor.pro`)
|
|
43
|
+
: null,
|
|
44
|
+
internal_addresses: (s.ports ?? []).map((p) => ({
|
|
45
|
+
port: p.number,
|
|
46
|
+
address: internal(project, app, s.name, p.number),
|
|
47
|
+
})),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function ordered(inputs, existing = []) {
|
|
51
|
+
const ids = inputs.filter((s) => s.service_id).map((s) => s.service_id);
|
|
52
|
+
if (new Set(ids).size !== ids.length)
|
|
53
|
+
bad("Each service_id can be targeted only once.");
|
|
54
|
+
const names = new Set(inputs.map((s) => s.name));
|
|
55
|
+
if (names.size !== inputs.length)
|
|
56
|
+
bad("Service names must be unique.");
|
|
57
|
+
const owner = new Map();
|
|
58
|
+
for (const s of existing)
|
|
59
|
+
for (const v of s.volumes ?? [])
|
|
60
|
+
if (v.id)
|
|
61
|
+
owner.set(v.id, s.name);
|
|
62
|
+
const named = new Map();
|
|
63
|
+
for (const s of inputs)
|
|
64
|
+
for (const v of s.volumes ?? []) {
|
|
65
|
+
const by = (v.id && owner.get(v.id)) ?? named.get(v.name);
|
|
66
|
+
if (by && by !== s.name)
|
|
67
|
+
bad(`Volume ${v.name} is already mounted on ${by}; a volume belongs to one service. Give ${s.name} its own volume, or let it reach ${by} over an internal reference.`);
|
|
68
|
+
named.set(v.name, s.name);
|
|
69
|
+
}
|
|
70
|
+
const all = new Set([...names, ...existing.map((s) => s.name)]);
|
|
71
|
+
const result = [];
|
|
72
|
+
const visiting = new Set();
|
|
73
|
+
const done = new Set();
|
|
74
|
+
function visit(s) {
|
|
75
|
+
if (done.has(s.name))
|
|
76
|
+
return;
|
|
77
|
+
if (visiting.has(s.name))
|
|
78
|
+
bad("Service dependencies contain a cycle.");
|
|
79
|
+
visiting.add(s.name);
|
|
80
|
+
const refs = Object.values(s.environment_variables ?? {}).filter((v) => typeof v !== "string");
|
|
81
|
+
for (const dep of new Set([
|
|
82
|
+
...(s.depends_on ?? []),
|
|
83
|
+
...refs.map((r) => r.service),
|
|
84
|
+
])) {
|
|
85
|
+
if (!all.has(dep))
|
|
86
|
+
bad(`Unknown dependency: ${dep}.`);
|
|
87
|
+
const other = inputs.find((x) => x.name === dep);
|
|
88
|
+
if (other)
|
|
89
|
+
visit(other);
|
|
90
|
+
}
|
|
91
|
+
for (const ref of refs) {
|
|
92
|
+
const other = inputs.find((x) => x.name === ref.service);
|
|
93
|
+
const old = existing.find((x) => x.name === ref.service);
|
|
94
|
+
const targetPorts = other?.ports ?? old?.ports ?? [];
|
|
95
|
+
if (ref.exposure === "public") {
|
|
96
|
+
if (!targetPorts.some((p) => p.expose_to_internet &&
|
|
97
|
+
(ref.port === undefined || p.number === ref.port)))
|
|
98
|
+
bad(`Service ${ref.service} has no matching public port.`);
|
|
99
|
+
}
|
|
100
|
+
else if (!targetPorts.some((p) => p.number === ref.port))
|
|
101
|
+
bad(`Referenced port ${ref.port} is not configured on ${ref.service}.`);
|
|
102
|
+
}
|
|
103
|
+
visiting.delete(s.name);
|
|
104
|
+
done.add(s.name);
|
|
105
|
+
result.push(s);
|
|
106
|
+
}
|
|
107
|
+
inputs.forEach(visit);
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
111
|
+
/** Image name owned by `base`: the base itself or a UI-style `base-vN`. */
|
|
112
|
+
const namedFor = (base, name) => name === base || new RegExp(`^${escapeRe(base)}-v\\d+$`).test(name);
|
|
113
|
+
/**
|
|
114
|
+
* Unique UI-style image name. Each build gets its own name: `base-vN` after the
|
|
115
|
+
* highest existing version, or `base` itself when explicit and still free.
|
|
116
|
+
*/
|
|
117
|
+
function imageName(base, images, versioned) {
|
|
118
|
+
const top = Math.max(0, ...images.map((i) => +(new RegExp(`^${escapeRe(base)}-v(\\d+)$`).exec(i.name ?? "")?.[1] ?? 0)));
|
|
119
|
+
if (!versioned && !top && !images.some((i) => i.name === base))
|
|
120
|
+
return base;
|
|
121
|
+
return `${base}-v${top + 1}`;
|
|
122
|
+
}
|
|
123
|
+
const usable = (h) => !h.removed &&
|
|
124
|
+
!["not_available", "unavailable"].includes(h.availability?.status);
|
|
125
|
+
/** Smallest usable option meeting the minimums; GPU options only when a GPU is requested. */
|
|
126
|
+
function fitting(hardware, need) {
|
|
127
|
+
return hardware
|
|
128
|
+
.filter((h) => usable(h) &&
|
|
129
|
+
!!h.gpu === !!need.gpu &&
|
|
130
|
+
h.cpu_count >= (need.cpu ?? 0) &&
|
|
131
|
+
h.ram_gb >= (need.ram_gb ?? 0))
|
|
132
|
+
.sort((a, b) => a.cpu_count - b.cpu_count || a.ram_gb - b.ram_gb)[0];
|
|
133
|
+
}
|
|
134
|
+
/** What an agent may choose from: specs only, no names or IDs. */
|
|
135
|
+
function hardwareOptions(hardware) {
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
return hardware
|
|
138
|
+
.filter(usable)
|
|
139
|
+
.map((h) => ({
|
|
140
|
+
cpu: h.cpu_count,
|
|
141
|
+
ram_gb: h.ram_gb,
|
|
142
|
+
gpu: h.gpu ? (h.gpu.name ?? h.gpu.model ?? "yes") : null,
|
|
143
|
+
}))
|
|
144
|
+
.sort((a, b) => +!!a.gpu - +!!b.gpu || a.cpu - b.cpu || a.ram_gb - b.ram_gb)
|
|
145
|
+
.filter((o) => !seen.has(JSON.stringify(o)) && !!seen.add(JSON.stringify(o)));
|
|
146
|
+
}
|
|
147
|
+
export class DeploymentEngine {
|
|
148
|
+
api;
|
|
149
|
+
store;
|
|
150
|
+
/** Set by the server so a build retry can re-check and re-upload its sources. */
|
|
151
|
+
files;
|
|
152
|
+
logs;
|
|
153
|
+
/** Native statuses this MCP saw each service go through, oldest first. */
|
|
154
|
+
seen = new Map();
|
|
155
|
+
/**
|
|
156
|
+
* Adds what the MCP observed to a service view: the status changes it saw and the new
|
|
157
|
+
* containers it saw starting in the service log. Constructor reports neither, so an
|
|
158
|
+
* agent can tell a restart loop from a stable service. Follows the log to keep seeing.
|
|
159
|
+
*/
|
|
160
|
+
observed(v) {
|
|
161
|
+
const at = new Date().toISOString();
|
|
162
|
+
const history = this.seen.get(v.id) ?? [];
|
|
163
|
+
if (history.at(-1)?.status !== v.status) {
|
|
164
|
+
history.push({ status: v.status, at });
|
|
165
|
+
if (history.length > 20)
|
|
166
|
+
history.shift();
|
|
167
|
+
this.seen.set(v.id, history);
|
|
168
|
+
}
|
|
169
|
+
if (v.status === "running" || v.status === "starting")
|
|
170
|
+
this.logs?.follow(v.id, "service");
|
|
171
|
+
const restarts = this.logs?.restarts(v.id) ?? [];
|
|
172
|
+
return {
|
|
173
|
+
...v,
|
|
174
|
+
...(history.length > 1 ? { status_changes: history } : {}),
|
|
175
|
+
...(restarts.length
|
|
176
|
+
? { restarts_seen: restarts.length, restarted_at: restarts }
|
|
177
|
+
: {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** Builds of this operation that are still running get their log followed. */
|
|
181
|
+
followLogs(op, images) {
|
|
182
|
+
const ours = new Set(Object.values(op.images));
|
|
183
|
+
for (const im of images)
|
|
184
|
+
if (ours.has(im.id) && !["done", "error"].includes(im.build_status))
|
|
185
|
+
this.logs?.follow(im.id);
|
|
186
|
+
}
|
|
187
|
+
/** How long a file tool waits for a stopped desk storage to start. */
|
|
188
|
+
filesystemWaitMs = 45000;
|
|
189
|
+
/** HTTP status of a public URL, or null when nothing answers. */
|
|
190
|
+
probeUrl = async (url) => {
|
|
191
|
+
try {
|
|
192
|
+
const r = await fetch(url, {
|
|
193
|
+
redirect: "manual",
|
|
194
|
+
signal: AbortSignal.timeout(5000),
|
|
195
|
+
});
|
|
196
|
+
await r.body?.cancel();
|
|
197
|
+
return r.status;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
constructor(api, store) {
|
|
204
|
+
this.api = api;
|
|
205
|
+
this.store = store;
|
|
206
|
+
}
|
|
207
|
+
/** Hold a build while files uploaded to its disk may not have reached the builder yet. */
|
|
208
|
+
async settling(op, desk) {
|
|
209
|
+
const ms = this.files?.settleMs(desk) ?? 0;
|
|
210
|
+
if (!ms)
|
|
211
|
+
return false;
|
|
212
|
+
op.status = "preparing";
|
|
213
|
+
op.message = `Waiting ${Math.ceil(ms / 1000)} s for just-uploaded files to reach the builder.`;
|
|
214
|
+
await this.store.save(op);
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
deskLink(project, desk) {
|
|
218
|
+
return `${this.api.config.baseUrl}/research/projects/${project}/desks/${desk}/viewer/file`;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The platform briefly reports error while it retries, so an error counts only
|
|
222
|
+
* when seen twice for the same image. The first confirmed failure is rebuilt
|
|
223
|
+
* once under a new name, after re-uploading (local_path) and re-checking the
|
|
224
|
+
* Dockerfile on the disk.
|
|
225
|
+
*/
|
|
226
|
+
async buildError(op, key, image, source) {
|
|
227
|
+
op.errorSeen ??= {};
|
|
228
|
+
op.retried ??= {};
|
|
229
|
+
if (op.errorSeen[key] !== image.id) {
|
|
230
|
+
op.errorSeen[key] = image.id;
|
|
231
|
+
return "wait";
|
|
232
|
+
}
|
|
233
|
+
if (op.retried[key])
|
|
234
|
+
return "fail";
|
|
235
|
+
op.retried[key] = image.name ?? image.id;
|
|
236
|
+
delete op.errorSeen[key];
|
|
237
|
+
if (this.files && source.local_path)
|
|
238
|
+
try {
|
|
239
|
+
await this.files.sync({
|
|
240
|
+
project_id: op.project_id,
|
|
241
|
+
desk_id: source.desk_id,
|
|
242
|
+
local_path: source.local_path,
|
|
243
|
+
remote_path: source.remote_path ?? "",
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
if (err instanceof ToolError && err.code === "needs_auth")
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
if (this.files) {
|
|
251
|
+
const pre = await this.files.preflight(op.project_id, [
|
|
252
|
+
{ service: key, ...source, ports: [] },
|
|
253
|
+
]);
|
|
254
|
+
if (pre.missing.length)
|
|
255
|
+
return "fail";
|
|
256
|
+
}
|
|
257
|
+
delete op.images[key];
|
|
258
|
+
op.message = `Build for ${key} failed; the MCP re-checked the source files and is rebuilding once under a new name.`;
|
|
259
|
+
return "retry";
|
|
260
|
+
}
|
|
261
|
+
async buildFailed(op, key, image, deskId, dockerfile, service) {
|
|
262
|
+
const link = this.deskLink(op.project_id, deskId);
|
|
263
|
+
const first = op.retried?.[key];
|
|
264
|
+
op.status = "failed";
|
|
265
|
+
op.failure = {
|
|
266
|
+
stage: "build",
|
|
267
|
+
service,
|
|
268
|
+
dockerfile,
|
|
269
|
+
image_id: image.id,
|
|
270
|
+
image_name: image.name,
|
|
271
|
+
first_image_name: first,
|
|
272
|
+
desk_link: link,
|
|
273
|
+
};
|
|
274
|
+
const log = await this.logs?.peek(image.id, "build", 20);
|
|
275
|
+
if (log)
|
|
276
|
+
op.failure.log_tail = log.lines;
|
|
277
|
+
op.message = `Image build${service ? ` for ${service}` : ""} failed${first ? ` twice (${first}, then ${image.name} after re-checking the files)` : ` (${image.name})`}. ${log
|
|
278
|
+
? "The last build log lines are in failure.log_tail (get_logs with the image ID shows up to 50)."
|
|
279
|
+
: `No build log was captured. To see the error, open the desk (${link}), rebuild ${dockerfile} from the UI and watch the live log.`} Fix the Dockerfile or source, then deploy again.`;
|
|
280
|
+
}
|
|
281
|
+
/** Poll public URLs after start until they answer or readinessSeconds pass. */
|
|
282
|
+
async ready(op, appId) {
|
|
283
|
+
const seconds = this.api.config.readinessSeconds ?? 60;
|
|
284
|
+
if (!seconds)
|
|
285
|
+
return true;
|
|
286
|
+
const ids = Object.values(op.services);
|
|
287
|
+
for (const id of ids)
|
|
288
|
+
this.logs?.follow(id, "service");
|
|
289
|
+
const targets = (await this.app(op.project_id, appId)).services
|
|
290
|
+
.filter((s) => ids.includes(s.id) && s.ports?.some((p) => p.expose_to_internet))
|
|
291
|
+
.map((s) => publicService(s, op.project_id, appId));
|
|
292
|
+
op.readiness ??= { since: Date.now(), checks: {} };
|
|
293
|
+
for (const t of targets) {
|
|
294
|
+
if (op.readiness.checks[t.name]?.ok)
|
|
295
|
+
continue;
|
|
296
|
+
const status = await this.probeUrl(t.public_url);
|
|
297
|
+
op.readiness.checks[t.name] = {
|
|
298
|
+
url: t.public_url,
|
|
299
|
+
status,
|
|
300
|
+
ok: status !== null && status < 500,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (targets.every((t) => op.readiness.checks[t.name].ok))
|
|
304
|
+
return true;
|
|
305
|
+
if (Date.now() - op.readiness.since <= seconds * 1000)
|
|
306
|
+
return false;
|
|
307
|
+
// Not answering: services that printed nothing since start most likely exit before their first line.
|
|
308
|
+
op.readiness.silent = [];
|
|
309
|
+
for (const [name, id] of Object.entries(op.services))
|
|
310
|
+
if (this.logs && !(await this.logs.peek(id, "service", 1)))
|
|
311
|
+
op.readiness.silent.push(name);
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
root(project) {
|
|
315
|
+
return `/projects/${e(project)}`;
|
|
316
|
+
}
|
|
317
|
+
async rows(path) {
|
|
318
|
+
const d = await this.api.request("GET", path);
|
|
319
|
+
if (!Array.isArray(d.results) ||
|
|
320
|
+
(typeof d.total === "number" && d.total > d.results.length))
|
|
321
|
+
throw new ToolError("incomplete_response", "API returned incomplete discovery results; refusing ambiguous resource selection.");
|
|
322
|
+
return d.results;
|
|
323
|
+
}
|
|
324
|
+
async project(id) {
|
|
325
|
+
const p = await this.api.request("GET", this.root(id));
|
|
326
|
+
if (p.id !== id)
|
|
327
|
+
bad("Project response does not match requested project.");
|
|
328
|
+
if (p.archived || p.status === "archived")
|
|
329
|
+
bad("Project is archived.");
|
|
330
|
+
return p;
|
|
331
|
+
}
|
|
332
|
+
async apps(project) {
|
|
333
|
+
return this.rows(this.root(project) + "/applications");
|
|
334
|
+
}
|
|
335
|
+
async app(project, id) {
|
|
336
|
+
return (one((await this.apps(project)).filter((a) => a.id === id), "Ambiguous application ID.") ?? bad("Application does not belong to this project or does not exist."));
|
|
337
|
+
}
|
|
338
|
+
async images(project, desk) {
|
|
339
|
+
return this.rows(this.root(project) + "/images" + (desk ? `?desk_id=${e(desk)}` : ""));
|
|
340
|
+
}
|
|
341
|
+
async desk(project, id) {
|
|
342
|
+
const d = await this.api.request("GET", `/desks/${e(id)}`);
|
|
343
|
+
if (d.project_id !== project)
|
|
344
|
+
bad("Disk does not belong to the selected project.");
|
|
345
|
+
if (d.state && d.state !== "ready")
|
|
346
|
+
throw new ToolError("not_ready", "Disk is not ready; retry later.");
|
|
347
|
+
return d;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* The desk record plus a running file storage. A desk nobody opened recently has
|
|
351
|
+
* its storage stopped (WebDAV answers 502/503), so start it as the browser does
|
|
352
|
+
* and wait; it takes about 30-40 s.
|
|
353
|
+
*/
|
|
354
|
+
async filesDesk(project, id) {
|
|
355
|
+
let d = await this.desk(project, id);
|
|
356
|
+
let status = d.filesystem?.status;
|
|
357
|
+
if (status === undefined || status === "running")
|
|
358
|
+
return d;
|
|
359
|
+
if (status !== "starting") {
|
|
360
|
+
d = await this.api.request("POST", `/desks/${e(id)}/filesystem`);
|
|
361
|
+
status = d.filesystem?.status;
|
|
362
|
+
}
|
|
363
|
+
const until = Math.min(Date.now() + this.filesystemWaitMs, this.api.deadlineMs() - 5000);
|
|
364
|
+
while (status !== "running" && Date.now() < until) {
|
|
365
|
+
await delay(Math.min(3000, Math.max(0, until - Date.now())));
|
|
366
|
+
d = await this.desk(project, id);
|
|
367
|
+
status = d.filesystem?.status;
|
|
368
|
+
}
|
|
369
|
+
if (status === "running")
|
|
370
|
+
return d;
|
|
371
|
+
throw new ToolError("not_ready", `The desk's file storage was stopped (desk not used recently), so the MCP started it, as opening the desk in the browser does. It is still ${status ?? "starting"}; this takes about 30-40 s. Nothing was changed. Repeat the same call in a moment.`);
|
|
372
|
+
}
|
|
373
|
+
async listImages(raw) {
|
|
374
|
+
const input = listInput.parse(raw);
|
|
375
|
+
await this.project(input.project_id);
|
|
376
|
+
if (input.desk_id)
|
|
377
|
+
await this.desk(input.project_id, input.desk_id);
|
|
378
|
+
const results = await this.images(input.project_id, input.desk_id);
|
|
379
|
+
return {
|
|
380
|
+
results: input.detailed
|
|
381
|
+
? results
|
|
382
|
+
: results.map((x) => ({
|
|
383
|
+
id: x.id,
|
|
384
|
+
name: x.name,
|
|
385
|
+
desk_id: x.desk_id,
|
|
386
|
+
desk_name: x.desk_name,
|
|
387
|
+
dockerfile: x.dockerfile,
|
|
388
|
+
tag: x.tag,
|
|
389
|
+
build_status: x.build_status,
|
|
390
|
+
})),
|
|
391
|
+
total: results.length,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
async deploy(raw) {
|
|
395
|
+
const input = deployInput.parse(raw);
|
|
396
|
+
return input.deployment_id
|
|
397
|
+
? this.resume(input, "deploy")
|
|
398
|
+
: this.submit("deploy", input);
|
|
399
|
+
}
|
|
400
|
+
async update(raw) {
|
|
401
|
+
const input = updateInput.parse(raw);
|
|
402
|
+
return input.deployment_id
|
|
403
|
+
? this.resume(input, "update")
|
|
404
|
+
: this.submit("update", input);
|
|
405
|
+
}
|
|
406
|
+
async buildImage(raw) {
|
|
407
|
+
return this.submit("build", buildInput.parse(raw));
|
|
408
|
+
}
|
|
409
|
+
async lifecycle(kind, raw) {
|
|
410
|
+
return this.submit(kind, lifecycleInput.parse(raw));
|
|
411
|
+
}
|
|
412
|
+
async submit(kind, input) {
|
|
413
|
+
return this.store.exclusive(async () => {
|
|
414
|
+
input = { ...input };
|
|
415
|
+
delete input.wait_seconds;
|
|
416
|
+
delete input.deployment_id;
|
|
417
|
+
const key = JSON.stringify({ ...input, files_uploaded: undefined });
|
|
418
|
+
let id = input.request_id ?? randomUUID();
|
|
419
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
|
420
|
+
const h = createHash("sha256")
|
|
421
|
+
.update(`${this.store.endpoint}|${kind}|${id}`)
|
|
422
|
+
.digest("hex");
|
|
423
|
+
id = `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-a${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
424
|
+
}
|
|
425
|
+
let op = await this.store.load(id);
|
|
426
|
+
if (op) {
|
|
427
|
+
if (op.kind !== kind ||
|
|
428
|
+
(op.submission_key ??
|
|
429
|
+
JSON.stringify({ ...op.input, files_uploaded: undefined })) !== key)
|
|
430
|
+
bad("request_id was already used with different arguments.");
|
|
431
|
+
if (input.files_uploaded)
|
|
432
|
+
op.input.files_uploaded = true;
|
|
433
|
+
return this.advance(op);
|
|
434
|
+
}
|
|
435
|
+
// Validate known topology before any authenticated mutation; existing services are checked below.
|
|
436
|
+
if (kind === "deploy")
|
|
437
|
+
ordered(input.services);
|
|
438
|
+
op = {
|
|
439
|
+
id,
|
|
440
|
+
kind,
|
|
441
|
+
endpoint: this.store.endpoint,
|
|
442
|
+
submission_key: key,
|
|
443
|
+
input,
|
|
444
|
+
status: "preparing",
|
|
445
|
+
project_id: input.project_id,
|
|
446
|
+
application_id: input.application_id,
|
|
447
|
+
desks: Object.create(null),
|
|
448
|
+
images: Object.create(null),
|
|
449
|
+
services: Object.create(null),
|
|
450
|
+
applied: [],
|
|
451
|
+
stopped: [],
|
|
452
|
+
started: [],
|
|
453
|
+
created_at: new Date().toISOString(),
|
|
454
|
+
updated_at: new Date().toISOString(),
|
|
455
|
+
};
|
|
456
|
+
if (op.application_id && ["update", "start", "stop"].includes(kind))
|
|
457
|
+
op.replaced = await this.supersede(op);
|
|
458
|
+
await this.store.save(op);
|
|
459
|
+
return this.advance(op);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Ends unfinished deployments of the same application, so an older one cannot apply its
|
|
464
|
+
* images after the newer one. Builds it already submitted keep running on Constructor
|
|
465
|
+
* but are not used. Called under the store lock, so no step of theirs is mid-flight.
|
|
466
|
+
*/
|
|
467
|
+
async supersede(op) {
|
|
468
|
+
const replaced = [];
|
|
469
|
+
const mine = new Set((op.input.services ?? []).map((s) => s.name));
|
|
470
|
+
for (const id of await this.store.ids()) {
|
|
471
|
+
let old;
|
|
472
|
+
try {
|
|
473
|
+
old = await this.store.load(id);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (!old ||
|
|
479
|
+
old.id === op.id ||
|
|
480
|
+
old.application_id !== op.application_id ||
|
|
481
|
+
terminal.has(old.status) ||
|
|
482
|
+
old.pending)
|
|
483
|
+
continue;
|
|
484
|
+
old.status = "superseded";
|
|
485
|
+
old.message = `Replaced by deployment ${op.id} for the same application; this one stopped here.`;
|
|
486
|
+
await this.store.save(old);
|
|
487
|
+
replaced.push({
|
|
488
|
+
deployment_id: old.id,
|
|
489
|
+
services_not_included: op.kind === "update"
|
|
490
|
+
? (old.input.services ?? [])
|
|
491
|
+
.map((s) => s.name)
|
|
492
|
+
.filter((n) => !mine.has(n))
|
|
493
|
+
: [],
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
return replaced.length ? replaced : undefined;
|
|
497
|
+
}
|
|
498
|
+
async resume(input, kind) {
|
|
499
|
+
return this.store.exclusive(async () => {
|
|
500
|
+
const op = await this.store.load(input.deployment_id);
|
|
501
|
+
if (!op || op.kind !== kind)
|
|
502
|
+
bad("Deployment ID not found or operation type does not match.");
|
|
503
|
+
if (op.pending || op.status === "needs_reconciliation")
|
|
504
|
+
bad("Inspect the uncertain mutation in Constructor before submitting a new operation.");
|
|
505
|
+
if (input.project_id && input.project_id !== op.project_id)
|
|
506
|
+
bad("Project does not match the saved deployment.");
|
|
507
|
+
if (input.application_id && input.application_id !== op.application_id)
|
|
508
|
+
bad("Application does not match the saved deployment.");
|
|
509
|
+
const next = structuredClone(op.input.services);
|
|
510
|
+
if (input.services) {
|
|
511
|
+
if (new Set(input.services.map((s) => s.name)).size !==
|
|
512
|
+
input.services.length)
|
|
513
|
+
bad("Service overrides must be unique.");
|
|
514
|
+
const images = op.project_id ? await this.images(op.project_id) : [];
|
|
515
|
+
for (const override of input.services) {
|
|
516
|
+
const at = next.findIndex((s) => s.name === override.name);
|
|
517
|
+
if (at < 0)
|
|
518
|
+
bad("Resume overrides can only change services already in this deployment.");
|
|
519
|
+
if (override.image_id &&
|
|
520
|
+
!images.some((im) => im.id === override.image_id && im.build_status === "done"))
|
|
521
|
+
bad("Replacement image is not successfully built in this project.");
|
|
522
|
+
const saved = next[at];
|
|
523
|
+
next[at] = {
|
|
524
|
+
...saved,
|
|
525
|
+
...override,
|
|
526
|
+
...(override.environment_variables
|
|
527
|
+
? {
|
|
528
|
+
environment_variables: {
|
|
529
|
+
...saved.environment_variables,
|
|
530
|
+
...override.environment_variables,
|
|
531
|
+
},
|
|
532
|
+
}
|
|
533
|
+
: {}),
|
|
534
|
+
...(override.build
|
|
535
|
+
? { build: { ...saved.build, ...override.build } }
|
|
536
|
+
: {}),
|
|
537
|
+
};
|
|
538
|
+
if (override.image_id)
|
|
539
|
+
delete next[at].build;
|
|
540
|
+
if (override.build)
|
|
541
|
+
delete next[at].image_id;
|
|
542
|
+
}
|
|
543
|
+
// Validate topology before changing saved inputs; references to unchanged live services are allowed.
|
|
544
|
+
const existing = op.application_id && op.project_id
|
|
545
|
+
? (await this.app(op.project_id, op.application_id)).services
|
|
546
|
+
: [];
|
|
547
|
+
ordered(next, existing);
|
|
548
|
+
for (const override of input.services) {
|
|
549
|
+
if (override.image_id || override.build)
|
|
550
|
+
delete op.images[override.name];
|
|
551
|
+
if (override.build)
|
|
552
|
+
delete op.desks[override.name];
|
|
553
|
+
op.applied = op.applied.filter((n) => n !== override.name);
|
|
554
|
+
op.started = op.started.filter((n) => n !== override.name);
|
|
555
|
+
const serviceId = op.services[override.name];
|
|
556
|
+
op.stopped = op.stopped.filter((id) => id !== serviceId);
|
|
557
|
+
}
|
|
558
|
+
op.input.services = next;
|
|
559
|
+
}
|
|
560
|
+
if (input.files_uploaded)
|
|
561
|
+
op.input.files_uploaded = true;
|
|
562
|
+
if (input.services || !terminal.has(op.status)) {
|
|
563
|
+
op.status = "preparing";
|
|
564
|
+
delete op.failure;
|
|
565
|
+
delete op.message;
|
|
566
|
+
}
|
|
567
|
+
await this.store.save(op);
|
|
568
|
+
return this.advance(op);
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
async observe(id) {
|
|
572
|
+
const op = await this.store.load(id);
|
|
573
|
+
if (!op)
|
|
574
|
+
bad("Deployment ID not found.");
|
|
575
|
+
return this.view(op, false);
|
|
576
|
+
}
|
|
577
|
+
/** Map of every deployment-enabled project with its applications and services. */
|
|
578
|
+
async overview() {
|
|
579
|
+
const all = await this.rows("/projects");
|
|
580
|
+
const enabled = all.filter((p) => p.deployment_enabled);
|
|
581
|
+
const projects = [];
|
|
582
|
+
let next = 0;
|
|
583
|
+
await Promise.all(Array.from({ length: Math.min(6, enabled.length) }, async () => {
|
|
584
|
+
while (next < enabled.length) {
|
|
585
|
+
const p = enabled[next++];
|
|
586
|
+
const row = { project_id: p.id, project_name: p.name };
|
|
587
|
+
try {
|
|
588
|
+
row.applications = (await this.apps(p.id)).map((a) => ({
|
|
589
|
+
application_id: a.id,
|
|
590
|
+
application_name: a.name,
|
|
591
|
+
services: (a.services ?? []).map((s) => {
|
|
592
|
+
const v = publicService(s, p.id, a.id);
|
|
593
|
+
return {
|
|
594
|
+
service_id: v.id,
|
|
595
|
+
service_name: v.name,
|
|
596
|
+
status: v.status,
|
|
597
|
+
image_id: v.image_id,
|
|
598
|
+
public_url: v.public_url,
|
|
599
|
+
ports: (v.ports ?? []).map((x) => ({
|
|
600
|
+
number: x.number,
|
|
601
|
+
public: !!x.expose_to_internet,
|
|
602
|
+
})),
|
|
603
|
+
};
|
|
604
|
+
}),
|
|
605
|
+
}));
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
if (err instanceof ToolError && err.code === "needs_auth")
|
|
609
|
+
throw err;
|
|
610
|
+
row.error =
|
|
611
|
+
err instanceof ToolError
|
|
612
|
+
? err.message
|
|
613
|
+
: "Could not load applications.";
|
|
614
|
+
}
|
|
615
|
+
projects.push(row);
|
|
616
|
+
}
|
|
617
|
+
}));
|
|
618
|
+
const order = new Map(enabled.map((p, n) => [p.id, n]));
|
|
619
|
+
projects.sort((a, b) => order.get(a.project_id) - order.get(b.project_id));
|
|
620
|
+
const apps = projects.flatMap((p) => p.applications ?? []);
|
|
621
|
+
return {
|
|
622
|
+
summary: `${projects.length} of ${all.length} projects have deployment enabled; ${apps.length} applications, ${apps.reduce((n, a) => n + a.services.length, 0)} services. Statuses are native; started is not readiness.`,
|
|
623
|
+
projects,
|
|
624
|
+
projects_without_deployment: all
|
|
625
|
+
.filter((p) => !p.deployment_enabled)
|
|
626
|
+
.map((p) => p.name),
|
|
627
|
+
readiness: "unverified",
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
async get(raw) {
|
|
631
|
+
const input = getInput.parse(raw);
|
|
632
|
+
if (!input.deployment_id) {
|
|
633
|
+
if (!input.project_id)
|
|
634
|
+
return this.overview();
|
|
635
|
+
await this.project(input.project_id);
|
|
636
|
+
if (input.application_id) {
|
|
637
|
+
const a = await this.app(input.project_id, input.application_id);
|
|
638
|
+
return {
|
|
639
|
+
...a,
|
|
640
|
+
services: a.services.map((s) => this.observed(publicService(s, input.project_id, a.id))),
|
|
641
|
+
readiness: "unverified",
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
const [desks, images, applications, hardware] = await Promise.all([
|
|
645
|
+
this.rows(this.root(input.project_id) + "/desks"),
|
|
646
|
+
this.images(input.project_id),
|
|
647
|
+
this.apps(input.project_id),
|
|
648
|
+
this.rows(this.root(input.project_id) + "/hardware-configurations"),
|
|
649
|
+
]);
|
|
650
|
+
return {
|
|
651
|
+
project_id: input.project_id,
|
|
652
|
+
desks: desks.map((d) => ({
|
|
653
|
+
id: d.id,
|
|
654
|
+
name: d.name,
|
|
655
|
+
state: d.state,
|
|
656
|
+
storage_size_gb: d.storage_size_gb,
|
|
657
|
+
})),
|
|
658
|
+
images: images.map((i) => ({
|
|
659
|
+
id: i.id,
|
|
660
|
+
name: i.name,
|
|
661
|
+
desk_id: i.desk_id,
|
|
662
|
+
dockerfile: i.dockerfile,
|
|
663
|
+
build_status: i.build_status,
|
|
664
|
+
tag: i.tag,
|
|
665
|
+
})),
|
|
666
|
+
hardware_options: hardwareOptions(hardware),
|
|
667
|
+
applications: applications.map((a) => ({
|
|
668
|
+
...a,
|
|
669
|
+
services: a.services.map((s) => publicService(s, input.project_id, a.id)),
|
|
670
|
+
})),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
return this.store.exclusive(async () => {
|
|
674
|
+
const op = await this.store.load(input.deployment_id);
|
|
675
|
+
if (!op)
|
|
676
|
+
bad("Deployment ID not found.");
|
|
677
|
+
if (input.files_uploaded !== undefined) {
|
|
678
|
+
op.input.files_uploaded = input.files_uploaded;
|
|
679
|
+
await this.store.save(op);
|
|
680
|
+
}
|
|
681
|
+
return input.resume ? this.advance(op) : this.view(op, false);
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
async mutate(op, method, path, body, apply) {
|
|
685
|
+
const stage = /\/images$/.test(path)
|
|
686
|
+
? "build"
|
|
687
|
+
: /\/start$/.test(path)
|
|
688
|
+
? "start"
|
|
689
|
+
: /\/stop$/.test(path)
|
|
690
|
+
? "stop"
|
|
691
|
+
: path.includes("/services")
|
|
692
|
+
? "configure"
|
|
693
|
+
: "prepare";
|
|
694
|
+
const targetId = /\/(start|stop)$/.test(path)
|
|
695
|
+
? path.split("/").at(-2)
|
|
696
|
+
: path.split("/").at(-1);
|
|
697
|
+
const named = body?.name
|
|
698
|
+
? (op.input.services ?? []).find((s) => namedFor(s.build?.name ?? s.name, body?.name))
|
|
699
|
+
: undefined;
|
|
700
|
+
const mapped = Object.entries(op.services).find(([, id]) => id === targetId)?.[0];
|
|
701
|
+
op.action = {
|
|
702
|
+
stage,
|
|
703
|
+
service: named?.name ??
|
|
704
|
+
mapped ??
|
|
705
|
+
(["start", "stop"].includes(stage) ? op.action?.service : undefined),
|
|
706
|
+
dockerfile: stage === "build" ? body?.dockerfile : undefined,
|
|
707
|
+
};
|
|
708
|
+
op.pending = { method, path };
|
|
709
|
+
await this.store.save(op);
|
|
710
|
+
try {
|
|
711
|
+
const result = await this.api.request(method, path, body);
|
|
712
|
+
if (method === "POST" &&
|
|
713
|
+
!/\/(start|stop)$/.test(path) &&
|
|
714
|
+
!/^[a-zA-Z0-9_-]{4,128}$/.test(result?.id ?? ""))
|
|
715
|
+
throw new ToolError("uncertain_outcome", "Create response did not contain a valid resource ID. Inspect resources before retrying.");
|
|
716
|
+
apply(result);
|
|
717
|
+
op.revision = (op.revision ?? 0) + 1;
|
|
718
|
+
delete op.pending;
|
|
719
|
+
await this.store.save(op);
|
|
720
|
+
}
|
|
721
|
+
catch (err) {
|
|
722
|
+
if (err instanceof ToolError && err.code !== "uncertain_outcome") {
|
|
723
|
+
delete op.pending;
|
|
724
|
+
await this.store.save(op);
|
|
725
|
+
}
|
|
726
|
+
throw err;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
async advance(op) {
|
|
730
|
+
if (op.pending) {
|
|
731
|
+
op.status = "needs_reconciliation";
|
|
732
|
+
op.message =
|
|
733
|
+
"A previous mutation has an uncertain outcome. Use get_deployment(project_id) or list_images to inspect resources, then start a new request using their IDs; do not blindly repeat creation.";
|
|
734
|
+
await this.store.save(op);
|
|
735
|
+
return this.view(op);
|
|
736
|
+
}
|
|
737
|
+
if (terminal.has(op.status))
|
|
738
|
+
return this.view(op);
|
|
739
|
+
try {
|
|
740
|
+
op.message = undefined;
|
|
741
|
+
await this.api.request("GET", "/auth/session");
|
|
742
|
+
if (op.kind === "build")
|
|
743
|
+
return await this.advanceBuild(op);
|
|
744
|
+
if (op.kind === "start" || op.kind === "stop")
|
|
745
|
+
return await this.advanceLifecycle(op);
|
|
746
|
+
return await this.advanceApp(op);
|
|
747
|
+
}
|
|
748
|
+
catch (err) {
|
|
749
|
+
if (!(err instanceof ToolError))
|
|
750
|
+
throw err;
|
|
751
|
+
if (err.code === "invalid_input")
|
|
752
|
+
throw err;
|
|
753
|
+
op.status =
|
|
754
|
+
err.code === "needs_auth"
|
|
755
|
+
? "needs_auth"
|
|
756
|
+
: err.code === "uncertain_outcome"
|
|
757
|
+
? "needs_reconciliation"
|
|
758
|
+
: err.code === "connection_error" || err.code === "not_ready"
|
|
759
|
+
? "waiting"
|
|
760
|
+
: "failed";
|
|
761
|
+
op.message =
|
|
762
|
+
op.status === "failed"
|
|
763
|
+
? `${err.message} Please check the logs in Constructor.`
|
|
764
|
+
: err.message;
|
|
765
|
+
if (op.status === "failed") {
|
|
766
|
+
op.failure = op.action ?? {
|
|
767
|
+
stage: op.kind === "build" ? "build" : "deployment",
|
|
768
|
+
};
|
|
769
|
+
op.message = `${op.failure.stage} failed${op.failure.service ? ` for ${op.failure.service}` : ""}. Please check the logs in Constructor.`;
|
|
770
|
+
const id = op.failure.service && op.services[op.failure.service];
|
|
771
|
+
const log = id && (await this.logs?.peek(id, "service", 20));
|
|
772
|
+
if (log) {
|
|
773
|
+
op.failure = { ...op.failure, log_tail: log.lines };
|
|
774
|
+
op.message = `${op.failure.stage} failed for ${op.failure.service}. The last service log lines are in failure.log_tail (get_logs with the service ID shows up to 50).`;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
await this.store.save(op);
|
|
778
|
+
return this.view(op);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
async advanceBuild(op) {
|
|
782
|
+
const i = op.input;
|
|
783
|
+
await this.project(i.project_id);
|
|
784
|
+
await this.desk(i.project_id, i.desk_id);
|
|
785
|
+
if (!op.images.image) {
|
|
786
|
+
const p = await this.project(i.project_id);
|
|
787
|
+
if (!p.deployment_enabled) {
|
|
788
|
+
await this.mutate(op, "PATCH", this.root(i.project_id), { deployment_enabled: true }, () => {
|
|
789
|
+
op.status = "preparing";
|
|
790
|
+
});
|
|
791
|
+
return this.view(op);
|
|
792
|
+
}
|
|
793
|
+
if (await this.settling(op, i.desk_id))
|
|
794
|
+
return this.view(op);
|
|
795
|
+
const body = {
|
|
796
|
+
name: imageName(i.name, await this.images(i.project_id), false),
|
|
797
|
+
dockerfile: i.dockerfile,
|
|
798
|
+
...(i.ephemeral_build_disk_size_gb === undefined
|
|
799
|
+
? {}
|
|
800
|
+
: { ephemeral_build_disk_size_gb: i.ephemeral_build_disk_size_gb }),
|
|
801
|
+
};
|
|
802
|
+
await this.mutate(op, "POST", `/desks/${e(i.desk_id)}/images`, body, (d) => {
|
|
803
|
+
op.images.image = d.id;
|
|
804
|
+
op.status = "building";
|
|
805
|
+
});
|
|
806
|
+
return this.view(op);
|
|
807
|
+
}
|
|
808
|
+
const image = (await this.images(i.project_id, i.desk_id)).find((x) => x.id === op.images.image);
|
|
809
|
+
if (image)
|
|
810
|
+
this.followLogs(op, [image]);
|
|
811
|
+
op.status = image?.build_status === "done" ? "built" : "building";
|
|
812
|
+
if (image?.build_status === "error") {
|
|
813
|
+
const next = await this.buildError(op, "image", image, i);
|
|
814
|
+
if (next === "fail")
|
|
815
|
+
await this.buildFailed(op, "image", image, i.desk_id, i.dockerfile);
|
|
816
|
+
}
|
|
817
|
+
else if (op.errorSeen)
|
|
818
|
+
delete op.errorSeen.image;
|
|
819
|
+
await this.store.save(op);
|
|
820
|
+
return this.view(op);
|
|
821
|
+
}
|
|
822
|
+
async advanceLifecycle(op) {
|
|
823
|
+
const a = await this.app(op.project_id, op.application_id);
|
|
824
|
+
const requested = op.input.service_names ?? a.services.map((s) => s.name);
|
|
825
|
+
for (const name of requested)
|
|
826
|
+
if (!a.services.some((s) => s.name === name))
|
|
827
|
+
bad(`Service ${name} does not exist in this application.`);
|
|
828
|
+
const done = op.kind === "start" ? op.started : op.stopped;
|
|
829
|
+
for (const s of a.services.filter((s) => requested.includes(s.name))) {
|
|
830
|
+
if (done.includes(s.id))
|
|
831
|
+
continue;
|
|
832
|
+
if (op.kind === "start" && s.status === "stopping") {
|
|
833
|
+
op.status = "waiting";
|
|
834
|
+
await this.store.save(op);
|
|
835
|
+
return this.view(op);
|
|
836
|
+
}
|
|
837
|
+
if ((op.kind === "stop" && ["stopped", "stopping"].includes(s.status)) ||
|
|
838
|
+
(op.kind === "start" && ["running", "starting"].includes(s.status))) {
|
|
839
|
+
done.push(s.id);
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
op.action = { stage: op.kind, service: s.name };
|
|
843
|
+
await this.mutate(op, "POST", `/services/${e(s.id)}/${op.kind}`, undefined, () => {
|
|
844
|
+
done.push(s.id);
|
|
845
|
+
op.status =
|
|
846
|
+
done.length === requested.length
|
|
847
|
+
? op.kind === "start"
|
|
848
|
+
? "started"
|
|
849
|
+
: "stopped"
|
|
850
|
+
: "applying";
|
|
851
|
+
});
|
|
852
|
+
return this.view(op);
|
|
853
|
+
}
|
|
854
|
+
op.status = op.kind === "start" ? "started" : "stopped";
|
|
855
|
+
await this.store.save(op);
|
|
856
|
+
return this.view(op);
|
|
857
|
+
}
|
|
858
|
+
async advanceApp(op) {
|
|
859
|
+
const input = op.input;
|
|
860
|
+
// For a new project all sources must be builds; image IDs are project-scoped.
|
|
861
|
+
if (!op.project_id) {
|
|
862
|
+
if (input.services.some((s) => !s.build || s.build.desk_id || s.service_id))
|
|
863
|
+
bad("A new project requires build sources without existing disk/service IDs.");
|
|
864
|
+
const matches = (await this.rows("/projects")).filter((p) => p.name === (input.project_name ?? input.application_name));
|
|
865
|
+
if (matches.length)
|
|
866
|
+
bad("A project with this name exists. Discover its ID with get_deployment before proceeding.");
|
|
867
|
+
await this.mutate(op, "POST", "/projects", { name: input.project_name ?? input.application_name }, (p) => {
|
|
868
|
+
op.project_id = p.id;
|
|
869
|
+
op.status = "preparing";
|
|
870
|
+
});
|
|
871
|
+
return this.view(op);
|
|
872
|
+
}
|
|
873
|
+
const project = await this.project(op.project_id);
|
|
874
|
+
if (project.status && project.status !== "active")
|
|
875
|
+
throw new ToolError("not_ready", "Project is not active yet; retry later.");
|
|
876
|
+
const applications = await this.apps(op.project_id);
|
|
877
|
+
let app = op.application_id
|
|
878
|
+
? one(applications.filter((a) => a.id === op.application_id), "Ambiguous application ID.")
|
|
879
|
+
: undefined;
|
|
880
|
+
if (op.application_id && !app)
|
|
881
|
+
bad("Application does not belong to this project or does not exist.");
|
|
882
|
+
if (!app && applications.some((a) => a.name === input.application_name))
|
|
883
|
+
bad("Application name already exists. Use update_app with its application ID.");
|
|
884
|
+
const services = ordered(input.services, app?.services ?? []);
|
|
885
|
+
const [images, hardware] = await Promise.all([
|
|
886
|
+
this.images(op.project_id),
|
|
887
|
+
this.rows(this.root(op.project_id) + "/hardware-configurations"),
|
|
888
|
+
]);
|
|
889
|
+
const existing = (s) => {
|
|
890
|
+
const old = s.service_id
|
|
891
|
+
? (app?.services ?? []).find((x) => x.id === s.service_id)
|
|
892
|
+
: one((app?.services ?? []).filter((x) => x.name === s.name), "Service name is ambiguous; provide service_id.");
|
|
893
|
+
if (s.service_id && !old)
|
|
894
|
+
bad("service_id does not belong to this application.");
|
|
895
|
+
if (old &&
|
|
896
|
+
old.name !== s.name &&
|
|
897
|
+
(app?.services ?? []).some((x) => x.name === s.name && x.id !== old.id))
|
|
898
|
+
bad("Service name is already used.");
|
|
899
|
+
return old;
|
|
900
|
+
};
|
|
901
|
+
/** Requested minimums pick the smallest fitting option; otherwise keep the current or use the default. */
|
|
902
|
+
const hardwareFor = (s, old) => {
|
|
903
|
+
if (!s.hardware)
|
|
904
|
+
return old?.hardware_configuration_id ?? fitting(hardware, {})?.id;
|
|
905
|
+
const hw = fitting(hardware, s.hardware);
|
|
906
|
+
if (!hw)
|
|
907
|
+
bad(`No available hardware meets ${JSON.stringify(s.hardware)} for ${s.name}. Options: ${JSON.stringify(hardwareOptions(hardware))}.`);
|
|
908
|
+
return hw.id;
|
|
909
|
+
};
|
|
910
|
+
const resolvedIds = new Set();
|
|
911
|
+
for (const s of services) {
|
|
912
|
+
const old = existing(s);
|
|
913
|
+
if (old && resolvedIds.has(old.id))
|
|
914
|
+
bad("Each existing service can be targeted only once, whether selected by name or ID.");
|
|
915
|
+
if (old)
|
|
916
|
+
resolvedIds.add(old.id);
|
|
917
|
+
if (!old && !s.image_id && !s.build)
|
|
918
|
+
bad(`New service ${s.name} requires image_id or build.`);
|
|
919
|
+
if (s.image_id) {
|
|
920
|
+
const im = images.find((x) => x.id === s.image_id);
|
|
921
|
+
if (!im || im.build_status !== "done")
|
|
922
|
+
bad(`Image for ${s.name} is missing or not successfully built in this project.`);
|
|
923
|
+
}
|
|
924
|
+
const hw = hardwareFor(s, old);
|
|
925
|
+
if (!hw || !hardware.some((h) => h.id === hw && !h.removed))
|
|
926
|
+
bad(`No valid hardware configuration for ${s.name}.`);
|
|
927
|
+
if (s.build?.desk_id)
|
|
928
|
+
await this.desk(op.project_id, s.build.desk_id);
|
|
929
|
+
for (const key of ["ports", "volumes"]) {
|
|
930
|
+
for (const item of s[key] ?? []) {
|
|
931
|
+
if (item.id && !old?.[key].some((v) => v.id === item.id))
|
|
932
|
+
bad(`Unknown ${key} ID for ${s.name}.`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
// Removing or changing an existing volume wipes its data: ask the user once for the whole list.
|
|
937
|
+
const wiped = [];
|
|
938
|
+
for (const s of services) {
|
|
939
|
+
const old = existing(s);
|
|
940
|
+
if (!old || s.volumes === undefined)
|
|
941
|
+
continue;
|
|
942
|
+
for (const v of old.volumes ?? []) {
|
|
943
|
+
const next = s.volumes.find((x) => x.id ? x.id === v.id : x.name === v.name);
|
|
944
|
+
if (!next)
|
|
945
|
+
wiped.push(`${s.name}: ${v.name} (${v.mount_point}) removed`);
|
|
946
|
+
else if (next.name !== v.name ||
|
|
947
|
+
next.mount_point !== v.mount_point ||
|
|
948
|
+
next.size_gb !== v.size_gb)
|
|
949
|
+
wiped.push(`${s.name}: ${v.name} (${v.mount_point}) changed`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
if (wiped.length && !input.confirm_volume_wipe) {
|
|
953
|
+
op.status = "confirm_required";
|
|
954
|
+
op.volumes_to_wipe = wiped;
|
|
955
|
+
op.message = `These volumes would lose all their data: ${wiped.join("; ")}. Nothing was changed. Ask the user once; after approval repeat the same call with confirm_volume_wipe=true.`;
|
|
956
|
+
await this.store.save(op);
|
|
957
|
+
return this.view(op);
|
|
958
|
+
}
|
|
959
|
+
if (!project.deployment_enabled) {
|
|
960
|
+
await this.mutate(op, "PATCH", this.root(op.project_id), { deployment_enabled: true }, () => {
|
|
961
|
+
op.status = "preparing";
|
|
962
|
+
});
|
|
963
|
+
return this.view(op);
|
|
964
|
+
}
|
|
965
|
+
const builds = services.filter((s) => s.build);
|
|
966
|
+
for (const s of builds) {
|
|
967
|
+
if (op.desks[s.name])
|
|
968
|
+
continue;
|
|
969
|
+
if (s.build.desk_id) {
|
|
970
|
+
op.desks[s.name] = s.build.desk_id;
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
const desks = await this.rows(this.root(op.project_id) + "/desks");
|
|
974
|
+
// One shared upload disk for all sources without an explicit disk.
|
|
975
|
+
const shared = Object.entries(op.desks).find(([name]) => !services.find((s) => s.name === name)?.build?.desk_id)?.[1];
|
|
976
|
+
const chosen = shared ?? (desks.length === 1 ? desks[0].id : undefined);
|
|
977
|
+
if (chosen) {
|
|
978
|
+
op.desks[s.name] = chosen;
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
await this.mutate(op, "POST", this.root(op.project_id) + "/desks", {
|
|
982
|
+
name: `${input.application_name ?? app?.name ?? "application"}-source`,
|
|
983
|
+
}, (d) => {
|
|
984
|
+
op.desks[s.name] = d.id;
|
|
985
|
+
op.status = "preparing";
|
|
986
|
+
});
|
|
987
|
+
return this.view(op);
|
|
988
|
+
}
|
|
989
|
+
if (builds.length && !input.files_uploaded) {
|
|
990
|
+
op.status = "needs_upload";
|
|
991
|
+
op.message =
|
|
992
|
+
"Upload the source files and Dockerfiles to the listed disks with sync_files, then call get_deployment with files_uploaded=true.";
|
|
993
|
+
await this.store.save(op);
|
|
994
|
+
return this.view(op);
|
|
995
|
+
}
|
|
996
|
+
for (const s of builds) {
|
|
997
|
+
const deskId = op.desks[s.name];
|
|
998
|
+
await this.desk(op.project_id, deskId);
|
|
999
|
+
if (op.images[s.name])
|
|
1000
|
+
continue;
|
|
1001
|
+
if (await this.settling(op, deskId))
|
|
1002
|
+
return this.view(op);
|
|
1003
|
+
const b = s.build;
|
|
1004
|
+
const body = {
|
|
1005
|
+
name: imageName(b.name ?? s.name, images, !b.name),
|
|
1006
|
+
dockerfile: b.dockerfile,
|
|
1007
|
+
...(b.ephemeral_build_disk_size_gb === undefined
|
|
1008
|
+
? {}
|
|
1009
|
+
: { ephemeral_build_disk_size_gb: b.ephemeral_build_disk_size_gb }),
|
|
1010
|
+
};
|
|
1011
|
+
await this.mutate(op, "POST", `/desks/${e(deskId)}/images`, body, (im) => {
|
|
1012
|
+
op.images[s.name] = im.id;
|
|
1013
|
+
op.status = "building";
|
|
1014
|
+
});
|
|
1015
|
+
return this.view(op);
|
|
1016
|
+
}
|
|
1017
|
+
this.followLogs(op, images);
|
|
1018
|
+
for (const s of builds) {
|
|
1019
|
+
const im = images.find((x) => x.id === op.images[s.name]);
|
|
1020
|
+
if (im?.build_status === "error") {
|
|
1021
|
+
const source = {
|
|
1022
|
+
desk_id: op.desks[s.name],
|
|
1023
|
+
dockerfile: s.build.dockerfile,
|
|
1024
|
+
local_path: s.build.local_path,
|
|
1025
|
+
remote_path: s.build.remote_path,
|
|
1026
|
+
};
|
|
1027
|
+
const next = await this.buildError(op, s.name, im, source);
|
|
1028
|
+
op.status = "building";
|
|
1029
|
+
if (next === "fail")
|
|
1030
|
+
await this.buildFailed(op, s.name, im, source.desk_id, source.dockerfile, s.name);
|
|
1031
|
+
await this.store.save(op);
|
|
1032
|
+
return this.view(op);
|
|
1033
|
+
}
|
|
1034
|
+
if (op.errorSeen)
|
|
1035
|
+
delete op.errorSeen[s.name];
|
|
1036
|
+
if (im?.build_status !== "done") {
|
|
1037
|
+
op.status = "building";
|
|
1038
|
+
await this.store.save(op);
|
|
1039
|
+
return this.view(op);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
if (!app) {
|
|
1043
|
+
await this.mutate(op, "POST", this.root(op.project_id) + "/applications", { name: input.application_name }, (a) => {
|
|
1044
|
+
op.application_id = a.id;
|
|
1045
|
+
op.status = "configuring";
|
|
1046
|
+
});
|
|
1047
|
+
return this.view(op);
|
|
1048
|
+
}
|
|
1049
|
+
for (const s of services) {
|
|
1050
|
+
let old = existing(s);
|
|
1051
|
+
if (old)
|
|
1052
|
+
op.services[s.name] = old.id;
|
|
1053
|
+
const serviceId = op.services[s.name] ?? old?.id;
|
|
1054
|
+
if (!op.applied.includes(s.name)) {
|
|
1055
|
+
if (old &&
|
|
1056
|
+
["running", "starting"].includes(old.status) &&
|
|
1057
|
+
!op.stopped.includes(old.id)) {
|
|
1058
|
+
await this.mutate(op, "POST", `/services/${e(old.id)}/stop`, undefined, () => {
|
|
1059
|
+
op.stopped.push(old.id);
|
|
1060
|
+
op.status = "configuring";
|
|
1061
|
+
});
|
|
1062
|
+
return this.view(op);
|
|
1063
|
+
}
|
|
1064
|
+
if (old && old.status === "stopping") {
|
|
1065
|
+
op.status = "waiting";
|
|
1066
|
+
await this.store.save(op);
|
|
1067
|
+
return this.view(op);
|
|
1068
|
+
}
|
|
1069
|
+
const body = this.serviceBody(s, old, op, hardwareFor(s, old), app.services);
|
|
1070
|
+
const path = old
|
|
1071
|
+
? `/applications/${e(app.id)}/services/${e(old.id)}`
|
|
1072
|
+
: `/applications/${e(app.id)}/services`;
|
|
1073
|
+
await this.mutate(op, old ? "PATCH" : "POST", path, body, (d) => {
|
|
1074
|
+
op.services[s.name] = old?.id ?? d.id;
|
|
1075
|
+
op.applied.push(s.name);
|
|
1076
|
+
op.status = "configuring";
|
|
1077
|
+
});
|
|
1078
|
+
return this.view(op);
|
|
1079
|
+
}
|
|
1080
|
+
if (!op.started.includes(s.name)) {
|
|
1081
|
+
if (!serviceId)
|
|
1082
|
+
bad("Service ID missing from checkpoint.");
|
|
1083
|
+
await this.mutate(op, "POST", `/services/${e(serviceId)}/start`, undefined, () => {
|
|
1084
|
+
op.started.push(s.name);
|
|
1085
|
+
op.status = "starting";
|
|
1086
|
+
});
|
|
1087
|
+
// A restarted container starts with an empty log: follow from the start.
|
|
1088
|
+
this.logs?.follow(serviceId, "service");
|
|
1089
|
+
return this.view(op);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
if (!(await this.ready(op, app.id))) {
|
|
1093
|
+
op.status = "verifying";
|
|
1094
|
+
await this.store.save(op);
|
|
1095
|
+
return this.view(op);
|
|
1096
|
+
}
|
|
1097
|
+
op.status = "started";
|
|
1098
|
+
await this.store.save(op);
|
|
1099
|
+
return this.view(op);
|
|
1100
|
+
}
|
|
1101
|
+
serviceBody(s, old, op, hardwareId, existingServices = []) {
|
|
1102
|
+
const ports = s.ports === undefined
|
|
1103
|
+
? (old?.ports ?? [])
|
|
1104
|
+
: s.ports.map((p) => ({
|
|
1105
|
+
...p,
|
|
1106
|
+
...(!p.id && old?.ports.find((v) => v.number === p.number)
|
|
1107
|
+
? { id: old.ports.find((v) => v.number === p.number).id }
|
|
1108
|
+
: {}),
|
|
1109
|
+
}));
|
|
1110
|
+
const volumes = s.volumes === undefined
|
|
1111
|
+
? (old?.volumes ?? [])
|
|
1112
|
+
: s.volumes.map((v) => ({
|
|
1113
|
+
...v,
|
|
1114
|
+
...(!v.id && old?.volumes.find((x) => x.name === v.name)
|
|
1115
|
+
? { id: old.volumes.find((x) => x.name === v.name).id }
|
|
1116
|
+
: {}),
|
|
1117
|
+
}));
|
|
1118
|
+
const env = new Map((old?.environment_variables ?? []).map((v) => [v.name, v]));
|
|
1119
|
+
for (const [name, raw] of Object.entries(s.environment_variables ?? {})) {
|
|
1120
|
+
let value;
|
|
1121
|
+
if (typeof raw === "string")
|
|
1122
|
+
value = raw;
|
|
1123
|
+
else if (raw.exposure === "public") {
|
|
1124
|
+
const target = existingServices.find((s) => s.id === op.services[raw.service]) ??
|
|
1125
|
+
existingServices.find((s) => s.name === raw.service);
|
|
1126
|
+
if (!target)
|
|
1127
|
+
bad(`Public service ${raw.service} has not been created.`);
|
|
1128
|
+
value = target.link ?? `https://${target.id}.constructor.pro`;
|
|
1129
|
+
}
|
|
1130
|
+
else
|
|
1131
|
+
value = `${raw.scheme ? raw.scheme + "://" : ""}${internal(op.project_id, op.application_id, raw.service, raw.port)}`;
|
|
1132
|
+
env.set(name, {
|
|
1133
|
+
...(env.get(name)?.id ? { id: env.get(name).id } : {}),
|
|
1134
|
+
name,
|
|
1135
|
+
value,
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
return {
|
|
1139
|
+
name: s.name,
|
|
1140
|
+
image_id: op.images[s.name] ?? s.image_id ?? old?.image_id,
|
|
1141
|
+
hardware_configuration_id: hardwareId,
|
|
1142
|
+
ports,
|
|
1143
|
+
volumes,
|
|
1144
|
+
environment_variables: [...env.values()],
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
/** readiness: http_ok / not_responding / no_public_url once checked, else unverified. */
|
|
1148
|
+
readinessView(op) {
|
|
1149
|
+
const checks = op.readiness?.checks ?? {};
|
|
1150
|
+
const names = Object.keys(checks);
|
|
1151
|
+
if (op.status !== "started" || !op.readiness)
|
|
1152
|
+
return {
|
|
1153
|
+
readiness: "unverified",
|
|
1154
|
+
message: op.message ?? (op.status === "started" ? NOTICE : undefined),
|
|
1155
|
+
};
|
|
1156
|
+
const failing = names.filter((n) => !checks[n].ok);
|
|
1157
|
+
const readiness = !names.length
|
|
1158
|
+
? "no_public_url"
|
|
1159
|
+
: failing.length
|
|
1160
|
+
? "not_responding"
|
|
1161
|
+
: "http_ok";
|
|
1162
|
+
return {
|
|
1163
|
+
readiness,
|
|
1164
|
+
readiness_checks: names.length ? checks : undefined,
|
|
1165
|
+
message: op.message ??
|
|
1166
|
+
(readiness === "http_ok"
|
|
1167
|
+
? `Started; the public URL answers (${names.map((n) => `${n}: HTTP ${checks[n].status}`).join(", ")}).`
|
|
1168
|
+
: readiness === "no_public_url"
|
|
1169
|
+
? "Started. No public port, so readiness was not checked over HTTP."
|
|
1170
|
+
: `Started, but ${failing.map((n) => `${n} (${checks[n].url}: ${checks[n].status === null ? "no answer" : `HTTP ${checks[n].status}`})`).join(", ")} did not answer within ${this.api.config.readinessSeconds ?? 60} s. ${silentHint(op.readiness.silent)}`),
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
async view(op, cache = true) {
|
|
1174
|
+
let services = [];
|
|
1175
|
+
let images = [];
|
|
1176
|
+
try {
|
|
1177
|
+
if (op.project_id && Object.keys(op.images).length) {
|
|
1178
|
+
const all = await this.images(op.project_id);
|
|
1179
|
+
images = Object.entries(op.images).map(([service, id]) => ({
|
|
1180
|
+
service,
|
|
1181
|
+
id,
|
|
1182
|
+
build_status: all.find((x) => x.id === id)?.build_status ?? "unknown",
|
|
1183
|
+
}));
|
|
1184
|
+
}
|
|
1185
|
+
if (op.application_id && op.project_id) {
|
|
1186
|
+
const a = await this.app(op.project_id, op.application_id);
|
|
1187
|
+
services = a.services.map((s) => this.observed(publicService(s, op.project_id, op.application_id)));
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
catch (err) {
|
|
1191
|
+
if (!(err instanceof ToolError))
|
|
1192
|
+
throw err;
|
|
1193
|
+
}
|
|
1194
|
+
const result = {
|
|
1195
|
+
deployment_id: op.id,
|
|
1196
|
+
operation: op.kind,
|
|
1197
|
+
status: op.status,
|
|
1198
|
+
project_id: op.project_id,
|
|
1199
|
+
application_id: op.application_id,
|
|
1200
|
+
images,
|
|
1201
|
+
services,
|
|
1202
|
+
failure: op.failure,
|
|
1203
|
+
build_retried: op.retried,
|
|
1204
|
+
replaced: op.replaced,
|
|
1205
|
+
volumes_to_wipe: op.volumes_to_wipe,
|
|
1206
|
+
...this.readinessView(op),
|
|
1207
|
+
...(op.status === "needs_upload"
|
|
1208
|
+
? {
|
|
1209
|
+
upload_targets: Object.entries(op.desks).map(([service, desk_id]) => ({
|
|
1210
|
+
service,
|
|
1211
|
+
desk_id,
|
|
1212
|
+
dockerfile: op.input.services.find((s) => s.name === service)?.build?.dockerfile,
|
|
1213
|
+
project_url: `${this.api.config.baseUrl}/research/projects/${op.project_id}`,
|
|
1214
|
+
})),
|
|
1215
|
+
}
|
|
1216
|
+
: {}),
|
|
1217
|
+
next_action: op.status === "failed"
|
|
1218
|
+
? "Tell the user which stage/service failed and pass on the message. failure.log_tail has the last log lines when the MCP captured them; get_logs shows more. Do not use browser or other tools to investigate."
|
|
1219
|
+
: terminal.has(op.status)
|
|
1220
|
+
? undefined
|
|
1221
|
+
: op.status === "needs_auth"
|
|
1222
|
+
? `${this.api.authHelp("The token expired or is invalid.")} Then call get_deployment with this deployment_id.`
|
|
1223
|
+
: op.status === "needs_upload"
|
|
1224
|
+
? "Upload files with sync_files, then call get_deployment with files_uploaded=true."
|
|
1225
|
+
: "Execution continues automatically in the MCP. Use get_deployment with wait_seconds=20 to wait for the result; do not orchestrate individual steps.",
|
|
1226
|
+
};
|
|
1227
|
+
if (cache) {
|
|
1228
|
+
op.result = result;
|
|
1229
|
+
await this.store.save(op);
|
|
1230
|
+
}
|
|
1231
|
+
return result;
|
|
1232
|
+
}
|
|
1233
|
+
}
|