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.
@@ -0,0 +1,238 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { terminal } from "./deployment.js";
3
+ import { ToolError } from "./api.js";
4
+ import { waitCommand } from "./config.js";
5
+ import { buildInput, deployInput, updateInput, lifecycleInput, getInput, } from "./schemas.js";
6
+ export const paused = new Set(["needs_auth", "needs_upload"]);
7
+ /** Tells the agent which unfinished deployments a new one ended, and what they changed that it does not. */
8
+ function replacedNote(replaced) {
9
+ if (!replaced?.length)
10
+ return undefined;
11
+ const dropped = [
12
+ ...new Set(replaced.flatMap((r) => r.services_not_included)),
13
+ ];
14
+ return `Replaced unfinished deployment ${replaced.map((r) => r.deployment_id).join(", ")} of this application${dropped.length
15
+ ? `; its changes to ${dropped.join(", ")} are not applied (repeat update_app including them if they should still change)`
16
+ : ""}.`;
17
+ }
18
+ /** Owns execution. The agent submits intent and observes results, never engine steps. */
19
+ export class DeploymentRuntime {
20
+ engine;
21
+ pollMilliseconds;
22
+ active = new Map();
23
+ closed = false;
24
+ constructor(engine, pollMilliseconds = 5000) {
25
+ this.engine = engine;
26
+ this.pollMilliseconds = pollMilliseconds;
27
+ }
28
+ async recover() {
29
+ for (const id of await this.engine.store.ids()) {
30
+ try {
31
+ const op = await this.engine.store.load(id);
32
+ if (op && !terminal.has(op.status) && op.status !== "needs_upload")
33
+ this.wake(id);
34
+ }
35
+ catch {
36
+ /* Corrupt/foreign checkpoints must not prevent MCP startup. */
37
+ }
38
+ }
39
+ }
40
+ async close() {
41
+ this.closed = true;
42
+ for (const job of this.active.values())
43
+ job.controller.abort();
44
+ await Promise.allSettled([...this.active.values()].map((j) => j.task));
45
+ }
46
+ async deploy(raw) {
47
+ const i = deployInput.parse(raw);
48
+ if (i.deployment_id)
49
+ await this.pause(i.deployment_id);
50
+ return this.execute(() => this.engine.deploy(i), i.wait_seconds);
51
+ }
52
+ async update(raw) {
53
+ const i = updateInput.parse(raw);
54
+ if (i.deployment_id)
55
+ await this.pause(i.deployment_id);
56
+ return this.execute(() => this.engine.update(i), i.wait_seconds);
57
+ }
58
+ async buildImage(raw) {
59
+ const i = buildInput.parse(raw);
60
+ return this.execute(() => this.engine.buildImage(i), i.wait_seconds);
61
+ }
62
+ async lifecycle(kind, raw) {
63
+ const i = lifecycleInput.parse(raw);
64
+ return this.execute(() => this.engine.lifecycle(kind, i), i.wait_seconds);
65
+ }
66
+ async get(raw) {
67
+ const i = getInput.parse(raw);
68
+ if (!i.deployment_id)
69
+ return this.engine.api.withBudget(20000, () => this.engine.get(i));
70
+ const start = Date.now();
71
+ if (i.files_uploaded !== undefined) {
72
+ await this.pause(i.deployment_id);
73
+ await this.engine.api.withBudget(15000, () => this.engine.get({ ...i, resume: true }));
74
+ }
75
+ let op = await this.engine.store.load(i.deployment_id);
76
+ if (op?.status === "needs_auth" &&
77
+ i.resume &&
78
+ i.files_uploaded === undefined) {
79
+ await this.pause(i.deployment_id);
80
+ await this.engine.api.withBudget(15000, () => this.engine.get({ ...i, resume: true }));
81
+ op = await this.engine.store.load(i.deployment_id);
82
+ }
83
+ if (!op)
84
+ throw new ToolError("invalid_input", "Deployment ID not found.");
85
+ if (i.resume && op.status !== "needs_upload" && !terminal.has(op.status))
86
+ this.wake(i.deployment_id);
87
+ const result = await this.wait(i.deployment_id, i.resume ? Math.max(0, i.wait_seconds * 1000 - (Date.now() - start)) : 0);
88
+ if (["started", "stopped", "built"].includes(result.status)) {
89
+ try {
90
+ const live = await this.engine.api.withBudget(3000, () => this.engine.observe(i.deployment_id));
91
+ if (live.services?.length)
92
+ result.services = live.services;
93
+ if (live.images?.length)
94
+ result.images = live.images;
95
+ }
96
+ catch {
97
+ /* Preserve the last known result if a read-only refresh is unavailable. */
98
+ }
99
+ }
100
+ return result;
101
+ }
102
+ async execute(fn, seconds) {
103
+ const start = Date.now();
104
+ const result = await this.engine.api.withBudget(15000, fn);
105
+ if (!terminal.has(result.status) && !paused.has(result.status))
106
+ this.wake(result.deployment_id);
107
+ return this.wait(result.deployment_id, Math.max(0, seconds * 1000 - (Date.now() - start)));
108
+ }
109
+ async pause(id) {
110
+ const job = this.active.get(id);
111
+ if (job) {
112
+ job.controller.abort();
113
+ await job.task;
114
+ }
115
+ }
116
+ wake(id) {
117
+ if (this.closed || this.active.has(id))
118
+ return;
119
+ const job = {
120
+ controller: new AbortController(),
121
+ task: Promise.resolve(),
122
+ };
123
+ this.active.set(id, job);
124
+ job.task = this.run(id, job.controller.signal).finally(() => {
125
+ if (this.active.get(id) === job)
126
+ this.active.delete(id);
127
+ });
128
+ }
129
+ async run(id, signal) {
130
+ const deadline = Date.now() + 60 * 60 * 1000;
131
+ while (!signal.aborted && !this.closed) {
132
+ try {
133
+ const before = await this.engine.store.load(id);
134
+ if (!before || terminal.has(before.status))
135
+ return;
136
+ await this.engine.store.beat(id);
137
+ if (Date.now() > deadline)
138
+ throw new ToolError("execution_timeout", "Deployment execution exceeded one hour. Please check the logs in Constructor.");
139
+ const result = await this.engine.api.withBudget(20000, () => this.engine.get({ deployment_id: id, resume: true, wait_seconds: 0 }));
140
+ if (terminal.has(result.status) || paused.has(result.status))
141
+ return;
142
+ const after = await this.engine.store.load(id);
143
+ if ((result.status === "building" || result.status === "waiting") &&
144
+ (after?.revision ?? 0) === (before.revision ?? 0))
145
+ await delay(this.pollMilliseconds, undefined, { signal });
146
+ }
147
+ catch (err) {
148
+ if (signal.aborted)
149
+ return;
150
+ if (err instanceof ToolError && err.code === "busy") {
151
+ await delay(Math.min(1000, this.pollMilliseconds), undefined, {
152
+ signal,
153
+ }).catch(() => { });
154
+ continue;
155
+ }
156
+ // API failures normally become structured engine results. Persist unexpected local failures too.
157
+ try {
158
+ await this.engine.store.exclusive(async () => {
159
+ const op = await this.engine.store.load(id);
160
+ if (!op)
161
+ return;
162
+ op.status = op.pending ? "needs_reconciliation" : "failed";
163
+ op.message =
164
+ err instanceof ToolError
165
+ ? `${err.message} Please check the logs in Constructor.`
166
+ : "Deployment execution failed. Please check the logs in Constructor.";
167
+ op.failure = { stage: "execution" };
168
+ await this.engine.store.save(op);
169
+ });
170
+ }
171
+ catch (writeError) {
172
+ if (writeError instanceof ToolError && writeError.code === "busy") {
173
+ await delay(Math.min(1000, this.pollMilliseconds), undefined, {
174
+ signal,
175
+ }).catch(() => { });
176
+ continue;
177
+ }
178
+ }
179
+ return;
180
+ }
181
+ }
182
+ }
183
+ async snapshot(id) {
184
+ const op = await this.engine.store.load(id);
185
+ if (!op)
186
+ throw new ToolError("invalid_input", "Deployment ID not found.");
187
+ // Derived from the checkpoint itself: op.result may predate the final step.
188
+ const ready = this.engine.readinessView(op);
189
+ return {
190
+ ...(op.result ??
191
+ (await this.engine.api.withBudget(15000, () => this.engine.observe(id)))),
192
+ deployment_id: op.id,
193
+ operation: op.kind,
194
+ status: op.status,
195
+ project_id: op.project_id,
196
+ application_id: op.application_id,
197
+ readiness: ready.readiness,
198
+ readiness_checks: ready.readiness_checks,
199
+ message: [ready.message ?? op.result?.message, replacedNote(op.replaced)]
200
+ .filter(Boolean)
201
+ .join(" ") || undefined,
202
+ replaced: op.replaced,
203
+ volumes_to_wipe: op.volumes_to_wipe,
204
+ failure: op.failure,
205
+ updated_at: op.updated_at,
206
+ execution: terminal.has(op.status)
207
+ ? "finished"
208
+ : paused.has(op.status)
209
+ ? "paused"
210
+ : "background",
211
+ next_action: op.status === "failed"
212
+ ? "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 investigate using browser or other tools."
213
+ : op.status === "needs_auth"
214
+ ? `${this.engine.api.authHelp("The token expired or is invalid.")} Then check this deployment again.`
215
+ : op.status === "needs_upload"
216
+ ? "Upload the listed files with sync_files, then call get_deployment with files_uploaded=true."
217
+ : op.status === "confirm_required"
218
+ ? "Ask the user once whether the listed volumes may lose their data; if yes, repeat the same update with confirm_volume_wipe=true."
219
+ : op.status === "needs_reconciliation"
220
+ ? "Inspect the uncertain mutation in Constructor before submitting a new operation."
221
+ : terminal.has(op.status)
222
+ ? undefined
223
+ : `The MCP is executing this deployment. Keep working on other things and check later with get_deployment. If nothing else is left, wait with the shell command \`${waitCommand(this.engine.api.config, [op.id])}\` (run it in the background if your client wakes you when a background command ends); it prints the outcome when the deployment finishes. Without a shell, call get_deployment with wait_seconds=20.`,
224
+ };
225
+ }
226
+ async wait(id, milliseconds) {
227
+ const deadline = Date.now() + milliseconds;
228
+ let result = await this.snapshot(id);
229
+ while (!terminal.has(result.status) &&
230
+ !paused.has(result.status) &&
231
+ Date.now() < deadline &&
232
+ !this.closed) {
233
+ await delay(Math.min(100, deadline - Date.now()));
234
+ result = await this.snapshot(id);
235
+ }
236
+ return result;
237
+ }
238
+ }
@@ -0,0 +1,232 @@
1
+ import { z } from "zod";
2
+ export declare const id: z.ZodString;
3
+ export declare const buildInput: z.ZodObject<{
4
+ project_id: z.ZodString;
5
+ desk_id: z.ZodString;
6
+ dockerfile: z.ZodString;
7
+ name: z.ZodString;
8
+ ephemeral_build_disk_size_gb: z.ZodOptional<z.ZodNumber>;
9
+ request_id: z.ZodOptional<z.ZodString>;
10
+ wait_seconds: z.ZodDefault<z.ZodNumber>;
11
+ }, z.core.$strict>;
12
+ export declare const listInput: z.ZodObject<{
13
+ project_id: z.ZodString;
14
+ desk_id: z.ZodOptional<z.ZodString>;
15
+ detailed: z.ZodDefault<z.ZodBoolean>;
16
+ }, z.core.$strict>;
17
+ export declare const serviceInput: z.ZodObject<{
18
+ name: z.ZodString;
19
+ service_id: z.ZodOptional<z.ZodString>;
20
+ image_id: z.ZodOptional<z.ZodString>;
21
+ build: z.ZodOptional<z.ZodObject<{
22
+ desk_id: z.ZodOptional<z.ZodString>;
23
+ dockerfile: z.ZodOptional<z.ZodString>;
24
+ local_path: z.ZodOptional<z.ZodString>;
25
+ remote_path: z.ZodOptional<z.ZodString>;
26
+ name: z.ZodOptional<z.ZodString>;
27
+ ephemeral_build_disk_size_gb: z.ZodOptional<z.ZodNumber>;
28
+ }, z.core.$strict>>;
29
+ hardware: z.ZodOptional<z.ZodObject<{
30
+ cpu: z.ZodOptional<z.ZodNumber>;
31
+ ram_gb: z.ZodOptional<z.ZodNumber>;
32
+ gpu: z.ZodOptional<z.ZodBoolean>;
33
+ }, z.core.$strict>>;
34
+ ports: z.ZodOptional<z.ZodArray<z.ZodObject<{
35
+ id: z.ZodOptional<z.ZodString>;
36
+ number: z.ZodNumber;
37
+ expose_to_internet: z.ZodBoolean;
38
+ }, z.core.$strict>>>;
39
+ volumes: z.ZodOptional<z.ZodArray<z.ZodObject<{
40
+ id: z.ZodOptional<z.ZodString>;
41
+ name: z.ZodString;
42
+ mount_point: z.ZodString;
43
+ size_gb: z.ZodNumber;
44
+ }, z.core.$strict>>>;
45
+ environment_variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
46
+ service: z.ZodString;
47
+ exposure: z.ZodDefault<z.ZodEnum<{
48
+ internal: "internal";
49
+ public: "public";
50
+ }>>;
51
+ port: z.ZodOptional<z.ZodNumber>;
52
+ scheme: z.ZodOptional<z.ZodEnum<{
53
+ http: "http";
54
+ https: "https";
55
+ }>>;
56
+ }, z.core.$strict>]>>>;
57
+ depends_on: z.ZodOptional<z.ZodArray<z.ZodString>>;
58
+ }, z.core.$strict>;
59
+ export declare const deskUrl: z.ZodString;
60
+ export declare const deployInput: z.ZodObject<{
61
+ project_id: z.ZodOptional<z.ZodString>;
62
+ desk_url: z.ZodOptional<z.ZodString>;
63
+ project_name: z.ZodOptional<z.ZodString>;
64
+ application_name: z.ZodOptional<z.ZodString>;
65
+ services: z.ZodOptional<z.ZodArray<z.ZodObject<{
66
+ name: z.ZodString;
67
+ service_id: z.ZodOptional<z.ZodString>;
68
+ image_id: z.ZodOptional<z.ZodString>;
69
+ build: z.ZodOptional<z.ZodObject<{
70
+ desk_id: z.ZodOptional<z.ZodString>;
71
+ dockerfile: z.ZodOptional<z.ZodString>;
72
+ local_path: z.ZodOptional<z.ZodString>;
73
+ remote_path: z.ZodOptional<z.ZodString>;
74
+ name: z.ZodOptional<z.ZodString>;
75
+ ephemeral_build_disk_size_gb: z.ZodOptional<z.ZodNumber>;
76
+ }, z.core.$strict>>;
77
+ hardware: z.ZodOptional<z.ZodObject<{
78
+ cpu: z.ZodOptional<z.ZodNumber>;
79
+ ram_gb: z.ZodOptional<z.ZodNumber>;
80
+ gpu: z.ZodOptional<z.ZodBoolean>;
81
+ }, z.core.$strict>>;
82
+ ports: z.ZodOptional<z.ZodArray<z.ZodObject<{
83
+ id: z.ZodOptional<z.ZodString>;
84
+ number: z.ZodNumber;
85
+ expose_to_internet: z.ZodBoolean;
86
+ }, z.core.$strict>>>;
87
+ volumes: z.ZodOptional<z.ZodArray<z.ZodObject<{
88
+ id: z.ZodOptional<z.ZodString>;
89
+ name: z.ZodString;
90
+ mount_point: z.ZodString;
91
+ size_gb: z.ZodNumber;
92
+ }, z.core.$strict>>>;
93
+ environment_variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
94
+ service: z.ZodString;
95
+ exposure: z.ZodDefault<z.ZodEnum<{
96
+ internal: "internal";
97
+ public: "public";
98
+ }>>;
99
+ port: z.ZodOptional<z.ZodNumber>;
100
+ scheme: z.ZodOptional<z.ZodEnum<{
101
+ http: "http";
102
+ https: "https";
103
+ }>>;
104
+ }, z.core.$strict>]>>>;
105
+ depends_on: z.ZodOptional<z.ZodArray<z.ZodString>>;
106
+ }, z.core.$strict>>>;
107
+ files_uploaded: z.ZodDefault<z.ZodBoolean>;
108
+ confirm: z.ZodDefault<z.ZodBoolean>;
109
+ confirm_volume_wipe: z.ZodDefault<z.ZodBoolean>;
110
+ request_id: z.ZodOptional<z.ZodString>;
111
+ wait_seconds: z.ZodDefault<z.ZodNumber>;
112
+ deployment_id: z.ZodOptional<z.ZodString>;
113
+ }, z.core.$strict>;
114
+ export declare const updateInput: z.ZodObject<{
115
+ project_id: z.ZodOptional<z.ZodString>;
116
+ desk_url: z.ZodOptional<z.ZodString>;
117
+ project_name: z.ZodOptional<z.ZodString>;
118
+ application_name: z.ZodOptional<z.ZodString>;
119
+ services: z.ZodOptional<z.ZodArray<z.ZodObject<{
120
+ name: z.ZodString;
121
+ service_id: z.ZodOptional<z.ZodString>;
122
+ image_id: z.ZodOptional<z.ZodString>;
123
+ build: z.ZodOptional<z.ZodObject<{
124
+ desk_id: z.ZodOptional<z.ZodString>;
125
+ dockerfile: z.ZodOptional<z.ZodString>;
126
+ local_path: z.ZodOptional<z.ZodString>;
127
+ remote_path: z.ZodOptional<z.ZodString>;
128
+ name: z.ZodOptional<z.ZodString>;
129
+ ephemeral_build_disk_size_gb: z.ZodOptional<z.ZodNumber>;
130
+ }, z.core.$strict>>;
131
+ hardware: z.ZodOptional<z.ZodObject<{
132
+ cpu: z.ZodOptional<z.ZodNumber>;
133
+ ram_gb: z.ZodOptional<z.ZodNumber>;
134
+ gpu: z.ZodOptional<z.ZodBoolean>;
135
+ }, z.core.$strict>>;
136
+ ports: z.ZodOptional<z.ZodArray<z.ZodObject<{
137
+ id: z.ZodOptional<z.ZodString>;
138
+ number: z.ZodNumber;
139
+ expose_to_internet: z.ZodBoolean;
140
+ }, z.core.$strict>>>;
141
+ volumes: z.ZodOptional<z.ZodArray<z.ZodObject<{
142
+ id: z.ZodOptional<z.ZodString>;
143
+ name: z.ZodString;
144
+ mount_point: z.ZodString;
145
+ size_gb: z.ZodNumber;
146
+ }, z.core.$strict>>>;
147
+ environment_variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
148
+ service: z.ZodString;
149
+ exposure: z.ZodDefault<z.ZodEnum<{
150
+ internal: "internal";
151
+ public: "public";
152
+ }>>;
153
+ port: z.ZodOptional<z.ZodNumber>;
154
+ scheme: z.ZodOptional<z.ZodEnum<{
155
+ http: "http";
156
+ https: "https";
157
+ }>>;
158
+ }, z.core.$strict>]>>>;
159
+ depends_on: z.ZodOptional<z.ZodArray<z.ZodString>>;
160
+ }, z.core.$strict>>>;
161
+ files_uploaded: z.ZodDefault<z.ZodBoolean>;
162
+ confirm: z.ZodDefault<z.ZodBoolean>;
163
+ confirm_volume_wipe: z.ZodDefault<z.ZodBoolean>;
164
+ request_id: z.ZodOptional<z.ZodString>;
165
+ wait_seconds: z.ZodDefault<z.ZodNumber>;
166
+ deployment_id: z.ZodOptional<z.ZodString>;
167
+ application_id: z.ZodOptional<z.ZodString>;
168
+ }, z.core.$strict>;
169
+ export declare const lifecycleInput: z.ZodObject<{
170
+ project_id: z.ZodString;
171
+ application_id: z.ZodString;
172
+ service_names: z.ZodOptional<z.ZodArray<z.ZodString>>;
173
+ wait_seconds: z.ZodDefault<z.ZodNumber>;
174
+ }, z.core.$strict>;
175
+ export declare const getInput: z.ZodObject<{
176
+ deployment_id: z.ZodOptional<z.ZodString>;
177
+ project_id: z.ZodOptional<z.ZodString>;
178
+ desk_url: z.ZodOptional<z.ZodString>;
179
+ application_id: z.ZodOptional<z.ZodString>;
180
+ files_uploaded: z.ZodOptional<z.ZodBoolean>;
181
+ resume: z.ZodDefault<z.ZodBoolean>;
182
+ wait_seconds: z.ZodDefault<z.ZodNumber>;
183
+ }, z.core.$strict>;
184
+ export declare const logsInput: z.ZodObject<{
185
+ image_id: z.ZodOptional<z.ZodString>;
186
+ service_id: z.ZodOptional<z.ZodString>;
187
+ deployment_id: z.ZodOptional<z.ZodString>;
188
+ lines: z.ZodDefault<z.ZodNumber>;
189
+ }, z.core.$strict>;
190
+ export type ServiceInput = z.infer<typeof serviceInput>;
191
+ export type DeploymentInput = z.infer<typeof deployInput> | z.infer<typeof updateInput>;
192
+ export declare const listFilesInput: z.ZodObject<{
193
+ project_id: z.ZodString;
194
+ desk_id: z.ZodString;
195
+ path: z.ZodDefault<z.ZodString>;
196
+ recursive: z.ZodDefault<z.ZodBoolean>;
197
+ max_entries: z.ZodDefault<z.ZodNumber>;
198
+ }, z.core.$strict>;
199
+ export declare const createFolderInput: z.ZodObject<{
200
+ project_id: z.ZodString;
201
+ desk_id: z.ZodString;
202
+ path: z.ZodString;
203
+ }, z.core.$strict>;
204
+ export declare const movePathInput: z.ZodObject<{
205
+ project_id: z.ZodString;
206
+ desk_id: z.ZodString;
207
+ moves: z.ZodArray<z.ZodObject<{
208
+ from: z.ZodString;
209
+ to: z.ZodString;
210
+ }, z.core.$strict>>;
211
+ confirm: z.ZodDefault<z.ZodBoolean>;
212
+ }, z.core.$strict>;
213
+ export declare const deletePathInput: z.ZodObject<{
214
+ project_id: z.ZodString;
215
+ desk_id: z.ZodString;
216
+ paths: z.ZodArray<z.ZodString>;
217
+ confirm: z.ZodDefault<z.ZodBoolean>;
218
+ }, z.core.$strict>;
219
+ export declare const syncFilesInput: z.ZodObject<{
220
+ project_id: z.ZodString;
221
+ desk_id: z.ZodString;
222
+ local_path: z.ZodString;
223
+ remote_path: z.ZodDefault<z.ZodString>;
224
+ exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
225
+ dry_run: z.ZodDefault<z.ZodBoolean>;
226
+ confirm: z.ZodDefault<z.ZodBoolean>;
227
+ }, z.core.$strict>;
228
+ export declare const signInInput: z.ZodObject<{
229
+ url: z.ZodOptional<z.ZodString>;
230
+ wait_seconds: z.ZodDefault<z.ZodNumber>;
231
+ force: z.ZodDefault<z.ZodBoolean>;
232
+ }, z.core.$strict>;