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/dist/api.js ADDED
@@ -0,0 +1,280 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ import { DEFAULT_BASE_URL } from "./config.js";
4
+ import { claims, readSession, saveSession, sessionHost, setCookies, } from "./session.js";
5
+ import { withLock } from "./store.js";
6
+ export class ToolError extends Error {
7
+ code;
8
+ status;
9
+ apiCode;
10
+ constructor(code, message, status, apiCode) {
11
+ super(message);
12
+ this.code = code;
13
+ this.status = status;
14
+ this.apiCode = apiCode;
15
+ }
16
+ }
17
+ export const segment = (s) => encodeURIComponent(s);
18
+ /** `exp` (seconds) of the ap_access_token JWT, if readable. */
19
+ export function expiry(cookie) {
20
+ const token = /(?:^|;\s*)ap_access_token=([^;\s]+)/.exec(cookie)?.[1];
21
+ const exp = claims(token)?.exp;
22
+ return typeof exp === "number" ? exp : undefined;
23
+ }
24
+ /** Renew the access token when it has less than this left (it lasts an hour). */
25
+ const RENEW_MS = 10 * 60000;
26
+ export class PlatformApi {
27
+ config;
28
+ deadline = new AsyncLocalStorage();
29
+ constructor(config) {
30
+ this.config = config;
31
+ }
32
+ /** End of the current call's time budget (epoch ms); Infinity outside one. */
33
+ deadlineMs() {
34
+ return this.deadline.getStore() ?? Infinity;
35
+ }
36
+ withBudget(milliseconds, fn) {
37
+ return this.deadline.run(Date.now() + milliseconds, fn);
38
+ }
39
+ /** How to supply a fresh token, with the exact file this MCP reads. */
40
+ authHelp(what = "Not signed in to Constructor.") {
41
+ const to = this.config.cookieFile;
42
+ return `${what} ${this.signingIn
43
+ ? "A sign-in window is already open: ask the user to finish signing in there, then call sign_in to confirm."
44
+ : "Tell the user a sign-in window is about to open, then call the sign_in tool: the user signs in in a separate Chrome window that closes by itself, and the MCP renews the session automatically for up to 14 days."} Without a desktop browser, the user runs: npx -y -p constructor-research-mcp constructor-research-set-token <file with a copied cURL> --to ${to}. Then repeat the same call.`;
45
+ }
46
+ /**
47
+ * Works with the Constructor the saved session belongs to; without one, with the host of the
48
+ * last desk link the user gave, or the default. CONSTRUCTOR_BASE_URL fixes it instead.
49
+ */
50
+ async followSession() {
51
+ if (this.config.baseUrlFixed)
52
+ return;
53
+ const host = await sessionHost(this.config.cookieFile);
54
+ this.config.baseUrl = host ?? this.aimed ?? DEFAULT_BASE_URL;
55
+ }
56
+ /** The host of a desk link given while nobody is signed in: sign_in opens there. */
57
+ aimed;
58
+ /** Set while the sign_in tool's window is open. */
59
+ signingIn = false;
60
+ /**
61
+ * Time left on the saved credentials: the login session when it can be renewed
62
+ * (`max_slt`, the latest renewal the server allows), else the access token.
63
+ */
64
+ async authLeft() {
65
+ const saved = await readSession(this.config.cookieFile);
66
+ const access = claims(saved.get("ap_access_token"));
67
+ const renewable = saved.has("ap_refresh_token");
68
+ const end = renewable ? access?.max_slt : access?.exp;
69
+ if (renewable && end === undefined)
70
+ return undefined;
71
+ if (typeof end !== "number")
72
+ return undefined;
73
+ return { minutes: (end * 1000 - Date.now()) / 60000, renewable };
74
+ }
75
+ renewing;
76
+ /** The Cookie header for a request: only the access token, renewed first when it runs low. */
77
+ async cookie() {
78
+ let saved;
79
+ try {
80
+ saved = await readSession(this.config.cookieFile);
81
+ }
82
+ catch {
83
+ saved = new Map();
84
+ }
85
+ let token = saved.get("ap_access_token");
86
+ const exp = expiry(`ap_access_token=${token}`);
87
+ // Never send one Constructor's session to another.
88
+ const owner = await sessionHost(this.config.cookieFile);
89
+ if (owner && owner !== this.config.baseUrl)
90
+ throw new ToolError("needs_auth", this.authHelp(`${this.config.cookieFile} holds a session for ${owner}, but this MCP works with ${this.config.baseUrl}.`));
91
+ if (saved.has("ap_refresh_token") &&
92
+ (!token || (exp !== undefined && exp * 1000 - Date.now() < RENEW_MS)))
93
+ token = await (this.renewing ??= this.renew().finally(() => (this.renewing = undefined)));
94
+ else if (!token)
95
+ throw new ToolError("needs_auth", this.authHelp());
96
+ else if (exp !== undefined && exp * 1000 <= Date.now())
97
+ throw new ToolError("needs_auth", this.authHelp(`Token expired at ${new Date(exp * 1000).toISOString().slice(11, 16)} UTC.`));
98
+ return `ap_access_token=${token}`;
99
+ }
100
+ /**
101
+ * Trades the refresh token for new tokens and saves them before anything else. A refresh
102
+ * token works once, so several MCP processes sharing the file renew under one lock, and
103
+ * a process that waited uses the tokens another one just saved.
104
+ */
105
+ renew() {
106
+ const file = this.config.cookieFile;
107
+ return withLock(file + ".lock", async () => {
108
+ const saved = await readSession(file);
109
+ const token = saved.get("ap_access_token");
110
+ const exp = expiry(`ap_access_token=${token}`);
111
+ const valid = !!token && (exp === undefined || exp * 1000 > Date.now());
112
+ const refresh = saved.get("ap_refresh_token");
113
+ if (!refresh ||
114
+ (exp !== undefined && exp * 1000 - Date.now() >= RENEW_MS))
115
+ return this.usable(token, valid);
116
+ let response;
117
+ try {
118
+ response = await fetch(this.config.baseUrl + this.config.apiPath + "/auth/session", {
119
+ // Without an access token the server must renew; the browser does the same.
120
+ headers: {
121
+ Cookie: `ap_refresh_token=${refresh}`,
122
+ Accept: "application/json",
123
+ },
124
+ redirect: "manual",
125
+ signal: AbortSignal.timeout(this.config.requestTimeoutMs),
126
+ });
127
+ }
128
+ catch {
129
+ if (valid)
130
+ return token;
131
+ throw new ToolError("connection_error", "Could not reach Constructor to renew the session; repeat the same call.");
132
+ }
133
+ await response.body?.cancel();
134
+ const fresh = setCookies(response);
135
+ if (fresh.has("ap_access_token") && fresh.has("ap_refresh_token")) {
136
+ await saveSession(file, new Map([...saved, ...fresh]));
137
+ return fresh.get("ap_access_token");
138
+ }
139
+ if (response.status === 401) {
140
+ // The refresh token is spent; drop it so the file behaves like a plain token.
141
+ saved.delete("ap_refresh_token");
142
+ await saveSession(file, saved);
143
+ if (valid)
144
+ return token;
145
+ throw new ToolError("needs_auth", this.authHelp("The saved login session can no longer be renewed: its refresh token was already used (a browser holding the same session renews it too) or the session reached its 14-day limit."));
146
+ }
147
+ if (valid)
148
+ return token;
149
+ throw new ToolError("connection_error", `Constructor answered HTTP ${response.status} without new tokens while renewing the session; repeat the same call.`);
150
+ }, 15000, () => new ToolError("busy", `Another process is renewing the session (${file}.lock); repeat the same call.`));
151
+ }
152
+ usable(token, valid) {
153
+ if (valid)
154
+ return token;
155
+ throw new ToolError("needs_auth", this.authHelp(token ? "Token expired." : undefined));
156
+ }
157
+ /**
158
+ * WebDAV request to a disk's project folder; callers interpret statuses other than 401.
159
+ * The storage gateway answers 502/503/504 now and then under load; requests that are
160
+ * safe to repeat (all but MOVE and DELETE) are retried a few times with backoff.
161
+ */
162
+ async storage(method, desk, path, init = {}) {
163
+ const cookie = await this.cookie();
164
+ const retries = ["MOVE", "DELETE"].includes(method)
165
+ ? []
166
+ : this.storageRetryMs;
167
+ for (let attempt = 0;; attempt++) {
168
+ const remaining = (this.deadline.getStore() ?? Infinity) - Date.now();
169
+ if (remaining <= 0)
170
+ throw new ToolError("connection_error", "Operation time budget exhausted; repeat the same call to continue.");
171
+ const wait = retries[attempt];
172
+ const again = wait !== undefined && wait < remaining - 1000;
173
+ let response;
174
+ try {
175
+ response = await fetch(this.config.baseUrl + this.davRoot(desk) + path, {
176
+ method,
177
+ headers: { Cookie: cookie, ...init.headers },
178
+ body: init.body,
179
+ redirect: "manual",
180
+ signal: AbortSignal.timeout(Math.max(1, Math.floor(Math.min(60000, remaining)))),
181
+ });
182
+ }
183
+ catch {
184
+ if (again) {
185
+ await delay(wait);
186
+ continue;
187
+ }
188
+ throw new ToolError("connection_error", "Storage connection failed or timed out. Repeating the same call is safe.");
189
+ }
190
+ if ([502, 503, 504].includes(response.status) && again) {
191
+ await response.body?.cancel();
192
+ await delay(wait);
193
+ continue;
194
+ }
195
+ if (response.status === 401)
196
+ throw new ToolError("needs_auth", this.authHelp("Constructor rejected the token (expired or invalid)."), 401);
197
+ return response;
198
+ }
199
+ }
200
+ /**
201
+ * Opens a build-log stream. It stays open while the build runs; the caller reads the
202
+ * body and aborts through `signal`. 401 becomes needs_auth; other statuses are returned.
203
+ */
204
+ async stream(path, signal) {
205
+ const cookie = await this.cookie();
206
+ let response;
207
+ try {
208
+ response = await fetch(this.config.baseUrl +
209
+ (this.config.keeperPath ?? "/api/platform-keeper") +
210
+ path, {
211
+ headers: { Cookie: cookie, Accept: "text/event-stream" },
212
+ redirect: "manual",
213
+ signal,
214
+ });
215
+ }
216
+ catch {
217
+ throw new ToolError("connection_error", "Build log connection failed or timed out.");
218
+ }
219
+ if (response.status === 401) {
220
+ await response.body?.cancel();
221
+ throw new ToolError("needs_auth", this.authHelp("Constructor rejected the token (expired or invalid)."), 401);
222
+ }
223
+ return response;
224
+ }
225
+ /** Backoff before each storage retry; tests shorten it. */
226
+ storageRetryMs = [1000, 2000, 4000];
227
+ /** Server-side path of a disk's project folder, used for WebDAV Destination headers. */
228
+ davRoot(desk) {
229
+ return `${this.config.storagePath ?? "/api/platform-storage"}/desks/${segment(desk)}/dav/project/`;
230
+ }
231
+ async request(method, path, body) {
232
+ const cookie = await this.cookie();
233
+ const remaining = (this.deadline.getStore() ?? Infinity) - Date.now();
234
+ if (remaining <= 0)
235
+ throw new ToolError("connection_error", "Operation time budget exhausted before sending the next request; resume with get_deployment.");
236
+ let response;
237
+ try {
238
+ response = await fetch(this.config.baseUrl + this.config.apiPath + path, {
239
+ method,
240
+ headers: {
241
+ Cookie: cookie,
242
+ Accept: "application/json",
243
+ ...(body === undefined ? {} : { "Content-Type": "application/json" }),
244
+ },
245
+ body: body === undefined ? undefined : JSON.stringify(body),
246
+ redirect: "manual",
247
+ signal: AbortSignal.timeout(Math.max(1, Math.floor(Math.min(this.config.requestTimeoutMs, remaining)))),
248
+ });
249
+ }
250
+ catch {
251
+ throw new ToolError(method === "GET" ? "connection_error" : "uncertain_outcome", method === "GET"
252
+ ? "API connection failed or timed out; retry the read."
253
+ : "The API request may have been applied. Inspect resources before retrying this mutation.");
254
+ }
255
+ if (response.status === 401)
256
+ throw new ToolError("needs_auth", this.authHelp("Constructor rejected the token (expired or invalid)."), 401);
257
+ if (!response.ok) {
258
+ if (method !== "GET" && response.status >= 500)
259
+ throw new ToolError("uncertain_outcome", `Constructor API returned HTTP ${response.status}. The mutation may have been applied; inspect resources before retrying.`, response.status);
260
+ let apiCode;
261
+ try {
262
+ const d = (await response.json());
263
+ if (typeof d?.details?.error_code === "number")
264
+ apiCode = d.details.error_code;
265
+ }
266
+ catch {
267
+ /* Error bodies can contain secrets; never echo them. */
268
+ }
269
+ throw new ToolError("api_error", `Constructor API returned HTTP ${response.status}${apiCode ? ` (error ${apiCode})` : ""}.`, response.status, apiCode);
270
+ }
271
+ if (response.status === 204)
272
+ return undefined;
273
+ try {
274
+ return (await response.json());
275
+ }
276
+ catch {
277
+ throw new ToolError(method === "GET" ? "invalid_response" : "uncertain_outcome", "API returned an invalid response. Inspect resources before retrying a mutation.");
278
+ }
279
+ }
280
+ }
@@ -0,0 +1,47 @@
1
+ export interface Config {
2
+ /** The Constructor the MCP works with: the signed-in one, unless CONSTRUCTOR_BASE_URL fixes it. */
3
+ baseUrl: string;
4
+ baseUrlFixed?: boolean;
5
+ apiPath: string;
6
+ storagePath?: string;
7
+ /** Build-log service root; build logs stream only while a build runs. */
8
+ keeperPath?: string;
9
+ /** Session file; each harness gets its own unless CONSTRUCTOR_COOKIE_FILE fixes one. */
10
+ cookieFile: string;
11
+ cookieFileFixed?: boolean;
12
+ stateDir: string;
13
+ requestTimeoutMs: number;
14
+ /** How long to wait for public URLs to answer after start; 0 disables the check. */
15
+ readinessSeconds?: number;
16
+ /** Pause between uploading to a disk and building from it; 0 disables. */
17
+ uploadSettleSeconds?: number;
18
+ }
19
+ /** Where the MCP works until the user signs in somewhere else. */
20
+ export declare const DEFAULT_BASE_URL = "https://constructor.app";
21
+ /** The origin of a Constructor link; HTTPS except on localhost. */
22
+ export declare function originOf(link: string): string;
23
+ export declare function configFromEnv(env?: NodeJS.ProcessEnv): Config;
24
+ /** Harnesses with their own session: one sign-in each, nothing shared between them. */
25
+ export declare const HARNESSES: readonly ["claude-code", "codex", "opencode"];
26
+ /** The harness behind an MCP client name (from the initialize handshake). */
27
+ export declare function harnessOf(client?: string): string;
28
+ /** Where a harness's Constructor session is kept (mode 600). */
29
+ export declare function sessionFile(harness: string): string;
30
+ /**
31
+ * Session file named on a command line: --for <harness> or --to <file>; without either,
32
+ * CONSTRUCTOR_COOKIE_FILE. Returns the remaining arguments too.
33
+ */
34
+ export declare function sessionArgs(args: string[], config: Config): {
35
+ file: string;
36
+ rest: string[];
37
+ } | {
38
+ file: undefined;
39
+ rest: string[];
40
+ };
41
+ /** Shell command that waits for deployments, carrying the settings that differ from the defaults. */
42
+ export declare function waitCommand(config: Config, ids: string[]): string;
43
+ /**
44
+ * How to run the wait CLI of this very version. Run through npx, the MCP lives in npx's
45
+ * cache, whose folder npm may replace at any time, so the hint names the package instead.
46
+ */
47
+ export declare function waitProgram(script?: string): string;
package/dist/config.js ADDED
@@ -0,0 +1,127 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ /** Where the MCP works until the user signs in somewhere else. */
6
+ export const DEFAULT_BASE_URL = "https://constructor.app";
7
+ /** The origin of a Constructor link; HTTPS except on localhost. */
8
+ export function originOf(link) {
9
+ let url;
10
+ try {
11
+ url = new URL(link.trim());
12
+ }
13
+ catch {
14
+ throw new Error(`${link} is not a valid link.`);
15
+ }
16
+ if (url.username || url.password)
17
+ throw new Error("The link must not carry credentials.");
18
+ if (url.protocol !== "https:" &&
19
+ !(url.protocol === "http:" &&
20
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
21
+ throw new Error("HTTPS is required except on localhost.");
22
+ return url.origin;
23
+ }
24
+ export function configFromEnv(env = process.env) {
25
+ const fixed = env.CONSTRUCTOR_BASE_URL;
26
+ if (fixed) {
27
+ const url = new URL(fixed);
28
+ if (url.search || url.hash || url.pathname !== "/")
29
+ throw new Error("CONSTRUCTOR_BASE_URL must be an origin without a path or credentials.");
30
+ }
31
+ const baseUrl = originOf(fixed ?? DEFAULT_BASE_URL);
32
+ const apiPath = env.CONSTRUCTOR_API_PATH ?? "/api/platform-account";
33
+ if (!/^\/[a-zA-Z0-9/_-]+$/.test(apiPath) || apiPath.includes("//"))
34
+ throw new Error("Invalid CONSTRUCTOR_API_PATH.");
35
+ const storagePath = env.CONSTRUCTOR_STORAGE_PATH ?? "/api/platform-storage";
36
+ if (!/^\/[a-zA-Z0-9/_-]+$/.test(storagePath) || storagePath.includes("//"))
37
+ throw new Error("Invalid CONSTRUCTOR_STORAGE_PATH.");
38
+ const keeperPath = env.CONSTRUCTOR_KEEPER_PATH ?? "/api/platform-keeper";
39
+ if (!/^\/[a-zA-Z0-9/_-]+$/.test(keeperPath) || keeperPath.includes("//"))
40
+ throw new Error("Invalid CONSTRUCTOR_KEEPER_PATH.");
41
+ const expand = (s) => resolve(s.startsWith("~/") ? homedir() + s.slice(1) : s);
42
+ return {
43
+ baseUrl,
44
+ baseUrlFixed: !!fixed,
45
+ apiPath: apiPath.replace(/\/$/, ""),
46
+ storagePath: storagePath.replace(/\/$/, ""),
47
+ keeperPath: keeperPath.replace(/\/$/, ""),
48
+ cookieFile: env.CONSTRUCTOR_COOKIE_FILE
49
+ ? expand(env.CONSTRUCTOR_COOKIE_FILE)
50
+ : sessionFile("default"),
51
+ cookieFileFixed: !!env.CONSTRUCTOR_COOKIE_FILE,
52
+ stateDir: expand(env.CONSTRUCTOR_STATE_DIR ?? "~/.local/state/constructor-research-mcp"),
53
+ requestTimeoutMs: 15000,
54
+ uploadSettleSeconds: Math.max(0, Math.min(300, Number(env.CONSTRUCTOR_UPLOAD_SETTLE_SECONDS ?? 30) || 0)),
55
+ readinessSeconds: Math.max(0, Math.min(300, Number(env.CONSTRUCTOR_READINESS_SECONDS ?? 60) || 0)),
56
+ };
57
+ }
58
+ /** Harnesses with their own session: one sign-in each, nothing shared between them. */
59
+ export const HARNESSES = ["claude-code", "codex", "opencode"];
60
+ /** The harness behind an MCP client name (from the initialize handshake). */
61
+ export function harnessOf(client) {
62
+ const name = (client ?? "").toLowerCase();
63
+ if (name.includes("claude"))
64
+ return "claude-code";
65
+ if (name.includes("codex"))
66
+ return "codex";
67
+ if (name.includes("opencode"))
68
+ return "opencode";
69
+ return name.replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "") || "default";
70
+ }
71
+ /** Where a harness's Constructor session is kept (mode 600). */
72
+ export function sessionFile(harness) {
73
+ return join(homedir(), ".config", "constructor-research", "sessions", `${harness}.txt`);
74
+ }
75
+ /**
76
+ * Session file named on a command line: --for <harness> or --to <file>; without either,
77
+ * CONSTRUCTOR_COOKIE_FILE. Returns the remaining arguments too.
78
+ */
79
+ export function sessionArgs(args, config) {
80
+ const value = (flag) => {
81
+ const i = args.indexOf(flag);
82
+ return i >= 0 ? args[i + 1] : undefined;
83
+ };
84
+ const to = value("--to"), harness = value("--for");
85
+ const rest = args.filter((_, n) => !["--to", "--for"].includes(args[n]) &&
86
+ !["--to", "--for"].includes(args[n - 1]));
87
+ if (to)
88
+ return { file: resolve(to), rest };
89
+ if (harness && HARNESSES.includes(harness))
90
+ return { file: sessionFile(harness), rest };
91
+ if (!harness && config.cookieFileFixed)
92
+ return { file: config.cookieFile, rest };
93
+ return { file: undefined, rest };
94
+ }
95
+ const quote = (s) => /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
96
+ /** Shell command that waits for deployments, carrying the settings that differ from the defaults. */
97
+ export function waitCommand(config, ids) {
98
+ const defaults = configFromEnv({});
99
+ const env = [
100
+ // The wait command reads the host from the session, as the MCP does, unless it is fixed.
101
+ [
102
+ "CONSTRUCTOR_BASE_URL",
103
+ config.baseUrlFixed ? config.baseUrl : undefined,
104
+ undefined,
105
+ ],
106
+ ["CONSTRUCTOR_API_PATH", config.apiPath, defaults.apiPath],
107
+ ["CONSTRUCTOR_STORAGE_PATH", config.storagePath, defaults.storagePath],
108
+ ["CONSTRUCTOR_KEEPER_PATH", config.keeperPath, defaults.keeperPath],
109
+ ["CONSTRUCTOR_COOKIE_FILE", config.cookieFile, defaults.cookieFile],
110
+ ["CONSTRUCTOR_STATE_DIR", config.stateDir, defaults.stateDir],
111
+ ]
112
+ .filter(([, value, standard]) => value !== standard)
113
+ .map(([name, value]) => `${name}=${quote(value)} `)
114
+ .join("");
115
+ return `${env}${waitProgram()} ${ids.join(" ")}`;
116
+ }
117
+ /**
118
+ * How to run the wait CLI of this very version. Run through npx, the MCP lives in npx's
119
+ * cache, whose folder npm may replace at any time, so the hint names the package instead.
120
+ */
121
+ export function waitProgram(script = fileURLToPath(import.meta.url)) {
122
+ const wait = join(script, "..", "wait.js");
123
+ if (!script.includes(`${sep}_npx${sep}`))
124
+ return `node ${quote(wait)}`;
125
+ const { name, version } = JSON.parse(readFileSync(join(script, "..", "..", "package.json"), "utf8"));
126
+ return `npx -y -p ${name}@${version} constructor-research-wait`;
127
+ }
@@ -0,0 +1,94 @@
1
+ import { PlatformApi } from "./api.js";
2
+ import { StateStore, type Operation } from "./store.js";
3
+ import type { FileManager } from "./files.js";
4
+ import type { LogFollower } from "./logs.js";
5
+ export declare const terminal: Set<string>;
6
+ export declare class DeploymentEngine {
7
+ readonly api: PlatformApi;
8
+ readonly store: StateStore;
9
+ /** Set by the server so a build retry can re-check and re-upload its sources. */
10
+ files?: FileManager;
11
+ logs?: LogFollower;
12
+ /** Native statuses this MCP saw each service go through, oldest first. */
13
+ private seen;
14
+ /**
15
+ * Adds what the MCP observed to a service view: the status changes it saw and the new
16
+ * containers it saw starting in the service log. Constructor reports neither, so an
17
+ * agent can tell a restart loop from a stable service. Follows the log to keep seeing.
18
+ */
19
+ private observed;
20
+ /** Builds of this operation that are still running get their log followed. */
21
+ private followLogs;
22
+ /** How long a file tool waits for a stopped desk storage to start. */
23
+ filesystemWaitMs: number;
24
+ /** HTTP status of a public URL, or null when nothing answers. */
25
+ probeUrl: (url: string) => Promise<number | null>;
26
+ constructor(api: PlatformApi, store: StateStore);
27
+ /** Hold a build while files uploaded to its disk may not have reached the builder yet. */
28
+ private settling;
29
+ private deskLink;
30
+ /**
31
+ * The platform briefly reports error while it retries, so an error counts only
32
+ * when seen twice for the same image. The first confirmed failure is rebuilt
33
+ * once under a new name, after re-uploading (local_path) and re-checking the
34
+ * Dockerfile on the disk.
35
+ */
36
+ private buildError;
37
+ private buildFailed;
38
+ /** Poll public URLs after start until they answer or readinessSeconds pass. */
39
+ private ready;
40
+ private root;
41
+ private rows;
42
+ private project;
43
+ private apps;
44
+ private app;
45
+ private images;
46
+ desk(project: string, id: string): Promise<any>;
47
+ /**
48
+ * The desk record plus a running file storage. A desk nobody opened recently has
49
+ * its storage stopped (WebDAV answers 502/503), so start it as the browser does
50
+ * and wait; it takes about 30-40 s.
51
+ */
52
+ filesDesk(project: string, id: string): Promise<any>;
53
+ listImages(raw: unknown): Promise<{
54
+ results: any[];
55
+ total: number;
56
+ }>;
57
+ deploy(raw: unknown): Promise<any>;
58
+ update(raw: unknown): Promise<any>;
59
+ buildImage(raw: unknown): Promise<any>;
60
+ lifecycle(kind: "start" | "stop", raw: unknown): Promise<any>;
61
+ private submit;
62
+ /**
63
+ * Ends unfinished deployments of the same application, so an older one cannot apply its
64
+ * images after the newer one. Builds it already submitted keep running on Constructor
65
+ * but are not used. Called under the store lock, so no step of theirs is mid-flight.
66
+ */
67
+ private supersede;
68
+ private resume;
69
+ observe(id: string): Promise<any>;
70
+ /** Map of every deployment-enabled project with its applications and services. */
71
+ private overview;
72
+ get(raw: unknown): Promise<any>;
73
+ private mutate;
74
+ private advance;
75
+ private advanceBuild;
76
+ private advanceLifecycle;
77
+ private advanceApp;
78
+ private serviceBody;
79
+ /** readiness: http_ok / not_responding / no_public_url once checked, else unverified. */
80
+ readinessView(op: Operation): {
81
+ readiness: string;
82
+ message: string | undefined;
83
+ readiness_checks?: undefined;
84
+ } | {
85
+ readiness: string;
86
+ readiness_checks: Record<string, {
87
+ url: string;
88
+ status: number | null;
89
+ ok: boolean;
90
+ }> | undefined;
91
+ message: string;
92
+ };
93
+ private view;
94
+ }