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,17 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Where a tool acts: a desk (project + desk), a project only, or a deployment-shaped input whose schema already allows a missing project_id. */
|
|
3
|
+
export type Scope = "desk" | "project" | "deploy";
|
|
4
|
+
/** Tool-facing schema: the engine schema plus desk_url, with the IDs it supplies made optional. */
|
|
5
|
+
export declare function located(schema: z.ZodObject<any>, scope: Exclude<Scope, "deploy">): z.ZodObject<{
|
|
6
|
+
[x: string]: any;
|
|
7
|
+
project_id: z.ZodOptional<z.ZodString>;
|
|
8
|
+
desk_id?: z.ZodOptional<z.ZodString> | undefined;
|
|
9
|
+
desk_url: z.ZodOptional<z.ZodString>;
|
|
10
|
+
}, z.core.$strip>;
|
|
11
|
+
/** Project and desk IDs from a desk link; the host must be the Constructor this MCP uses. */
|
|
12
|
+
export declare function parseDeskUrl(link: string, baseUrl: string, fixed?: boolean): {
|
|
13
|
+
project_id: string;
|
|
14
|
+
desk_id: string;
|
|
15
|
+
};
|
|
16
|
+
/** Replace desk_url with the IDs it carries; explicit IDs must agree with it. */
|
|
17
|
+
export declare function resolveDeskUrl(args: any, baseUrl: string, scope: Scope, fixed?: boolean): any;
|
package/dist/location.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ToolError } from "./api.js";
|
|
2
|
+
import { id, deskUrl } from "./schemas.js";
|
|
3
|
+
/** Tool-facing schema: the engine schema plus desk_url, with the IDs it supplies made optional. */
|
|
4
|
+
export function located(schema, scope) {
|
|
5
|
+
return schema
|
|
6
|
+
.extend({
|
|
7
|
+
project_id: id.optional(),
|
|
8
|
+
...("desk_id" in schema.shape ? { desk_id: id.optional() } : {}),
|
|
9
|
+
desk_url: deskUrl.optional(),
|
|
10
|
+
})
|
|
11
|
+
.refine((x) => !!x.desk_url ||
|
|
12
|
+
(!!x.project_id &&
|
|
13
|
+
(scope !== "desk" ||
|
|
14
|
+
!!x.desk_id ||
|
|
15
|
+
schema.shape.desk_id.safeParse(undefined).success)), scope === "desk"
|
|
16
|
+
? "Provide desk_url (the desk link from the browser) or project_id and desk_id."
|
|
17
|
+
: "Provide desk_url (the desk link from the browser) or project_id.");
|
|
18
|
+
}
|
|
19
|
+
function bad(code, message) {
|
|
20
|
+
throw new ToolError(code, message);
|
|
21
|
+
}
|
|
22
|
+
/** Project and desk IDs from a desk link; the host must be the Constructor this MCP uses. */
|
|
23
|
+
export function parseDeskUrl(link, baseUrl, fixed = false) {
|
|
24
|
+
let url;
|
|
25
|
+
try {
|
|
26
|
+
url = new URL(link.trim());
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
bad("invalid_input", "desk_url is not a valid link.");
|
|
30
|
+
}
|
|
31
|
+
const m = url.pathname.match(/\/projects\/([a-zA-Z0-9_-]{4,128})\/desks\/([a-zA-Z0-9_-]{4,128})(?:\/|$)/);
|
|
32
|
+
if (!m)
|
|
33
|
+
bad("invalid_input", "desk_url must contain /projects/<project>/desks/<desk>. Ask the user to open the desk in Constructor and copy the address bar.");
|
|
34
|
+
const base = new URL(baseUrl).origin;
|
|
35
|
+
if (url.origin !== base)
|
|
36
|
+
bad("wrong_environment", fixed
|
|
37
|
+
? `The link is for ${url.origin}, but this MCP is fixed to ${base} by CONSTRUCTOR_BASE_URL. Ask the user for a link from ${base}.`
|
|
38
|
+
: `The link is for ${url.origin}, but the user is signed in to ${base}. If they work on ${url.origin}, tell them a sign-in window is about to open, then call sign_in with url set to this link; that replaces the sign-in to ${base}.`);
|
|
39
|
+
return { project_id: m[1], desk_id: m[2] };
|
|
40
|
+
}
|
|
41
|
+
/** Replace desk_url with the IDs it carries; explicit IDs must agree with it. */
|
|
42
|
+
export function resolveDeskUrl(args, baseUrl, scope, fixed = false) {
|
|
43
|
+
if (!args?.desk_url)
|
|
44
|
+
return args;
|
|
45
|
+
const { desk_url, ...rest } = args;
|
|
46
|
+
const at = parseDeskUrl(desk_url, baseUrl, fixed);
|
|
47
|
+
if (rest.project_id && rest.project_id !== at.project_id)
|
|
48
|
+
bad("invalid_input", "project_id does not match the project in desk_url.");
|
|
49
|
+
rest.project_id = at.project_id;
|
|
50
|
+
if (scope === "desk") {
|
|
51
|
+
if (rest.desk_id && rest.desk_id !== at.desk_id)
|
|
52
|
+
bad("invalid_input", "desk_id does not match the desk in desk_url.");
|
|
53
|
+
rest.desk_id = at.desk_id;
|
|
54
|
+
}
|
|
55
|
+
if (scope === "deploy" && rest.services)
|
|
56
|
+
rest.services = rest.services.map((s) => s.build && !s.build.desk_id
|
|
57
|
+
? { ...s, build: { ...s.build, desk_id: at.desk_id } }
|
|
58
|
+
: s);
|
|
59
|
+
return rest;
|
|
60
|
+
}
|
package/dist/login.d.ts
ADDED
package/dist/login.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Sign in from a terminal; the MCP's sign_in tool does the same from an agent session.
|
|
3
|
+
// Usage: npx -y -p constructor-research-mcp constructor-research-login [--url <any link to your Constructor>] --for claude-code|codex|opencode (or --to <session file>)
|
|
4
|
+
import { configFromEnv, HARNESSES, originOf, sessionArgs } from "./config.js";
|
|
5
|
+
import { signIn } from "./signin.js";
|
|
6
|
+
const config = configFromEnv();
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const at = args.indexOf("--url");
|
|
9
|
+
const url = at >= 0 ? args.splice(at, 2)[1] : undefined;
|
|
10
|
+
const { file } = sessionArgs(args, config);
|
|
11
|
+
if (!file || (at >= 0 && !url)) {
|
|
12
|
+
console.error(`Usage: npx -y -p constructor-research-mcp constructor-research-login [--url <any link to your Constructor>] --for ${HARNESSES.join("|")} (or --to <session file>)`);
|
|
13
|
+
process.exit(2);
|
|
14
|
+
}
|
|
15
|
+
if (url) {
|
|
16
|
+
const host = originOf(url);
|
|
17
|
+
if (config.baseUrlFixed && host !== config.baseUrl) {
|
|
18
|
+
console.error(`CONSTRUCTOR_BASE_URL fixes the MCP to ${config.baseUrl}.`);
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
config.baseUrl = host;
|
|
22
|
+
}
|
|
23
|
+
const result = await signIn(config, file, () => console.log(`A Chrome window opened at ${config.baseUrl}. Sign in there; it closes by itself afterwards.`));
|
|
24
|
+
(result.status === "signed_in" ? console.log : console.error)(result.message);
|
|
25
|
+
process.exit(result.status === "signed_in" ? 0 : 1);
|
package/dist/logs.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { PlatformApi } from "./api.js";
|
|
2
|
+
/** Lines kept per build; the tool returns up to this many. */
|
|
3
|
+
export declare const KEEP = 50;
|
|
4
|
+
export type LogState = "live" | "finished" | "unavailable";
|
|
5
|
+
/** Every connection replays the log from the start (a service: its current container), then streams. */
|
|
6
|
+
export type LogKind = "build" | "service";
|
|
7
|
+
export interface LogView {
|
|
8
|
+
kind: LogKind;
|
|
9
|
+
id: string;
|
|
10
|
+
state: LogState;
|
|
11
|
+
lines: string[];
|
|
12
|
+
last_received_at?: string;
|
|
13
|
+
/** New containers seen starting while the MCP followed this service. */
|
|
14
|
+
restarts_seen?: number;
|
|
15
|
+
restarted_at?: string[];
|
|
16
|
+
message?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Follows build and service log streams and keeps only the last KEEP lines of each, in
|
|
20
|
+
* memory and in a small file in the state directory. The platform serves a build log only
|
|
21
|
+
* while the build runs, and every connection replays it from the start, so a dropped
|
|
22
|
+
* stream is reopened and already-seen lines are skipped. When the build ends the stream
|
|
23
|
+
* closes and reopening answers "not found"; the kept lines stay readable. A service log
|
|
24
|
+
* covers its current container only (a restarted container starts empty), so services are followed from their start
|
|
25
|
+
* and for a while after anyone last asked.
|
|
26
|
+
*/
|
|
27
|
+
export declare class LogFollower {
|
|
28
|
+
readonly api: PlatformApi;
|
|
29
|
+
private readonly dir;
|
|
30
|
+
private logs;
|
|
31
|
+
private readonly controller;
|
|
32
|
+
/** Backoff between reconnects; tests shorten it. */
|
|
33
|
+
retryMs: number[];
|
|
34
|
+
/** Reopen a stream that sent nothing for this long (a silent proxy timeout). */
|
|
35
|
+
idleMs: number;
|
|
36
|
+
/** How long to follow after the last interest: builds until they end, services briefly. */
|
|
37
|
+
followMs: Record<LogKind, number>;
|
|
38
|
+
constructor(api: PlatformApi, dir?: string);
|
|
39
|
+
/** Start following if nobody is; cheap to call on every poll. */
|
|
40
|
+
follow(id: string, kind?: LogKind): void;
|
|
41
|
+
/** Last `count` lines, following first if the build or service may still be running. */
|
|
42
|
+
tail(id: string, kind?: LogKind, count?: number, waitMs?: number): Promise<LogView>;
|
|
43
|
+
/** Kept lines without contacting the platform. */
|
|
44
|
+
peek(id: string, kind?: LogKind, count?: number): Promise<LogView | undefined>;
|
|
45
|
+
close(): Promise<void>;
|
|
46
|
+
/** New containers seen starting while this service was followed. */
|
|
47
|
+
restarts(id: string): string[];
|
|
48
|
+
private view;
|
|
49
|
+
private read;
|
|
50
|
+
private add;
|
|
51
|
+
/** Marks a new container in the kept lines. */
|
|
52
|
+
private restarted;
|
|
53
|
+
private keep;
|
|
54
|
+
/** At most every few seconds while streaming, and once at the end. */
|
|
55
|
+
private save;
|
|
56
|
+
private load;
|
|
57
|
+
/** Forget the oldest finished builds; their files remain. */
|
|
58
|
+
private prune;
|
|
59
|
+
}
|
package/dist/logs.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { ToolError, segment } from "./api.js";
|
|
5
|
+
/** Lines kept per build; the tool returns up to this many. */
|
|
6
|
+
export const KEEP = 50;
|
|
7
|
+
/** Longer lines are cut, so one runaway line cannot fill memory. */
|
|
8
|
+
const WIDTH = 1000;
|
|
9
|
+
const ID = /^[a-zA-Z0-9_-]{4,128}$/;
|
|
10
|
+
const STAMP = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z)(?: |$)/;
|
|
11
|
+
/** What the platform prints, unstamped, before a container's first line. */
|
|
12
|
+
const WAITING = /^Container is waiting to start/i;
|
|
13
|
+
/** Restart times kept per service. */
|
|
14
|
+
const RESTARTS = 20;
|
|
15
|
+
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
16
|
+
const PATH = {
|
|
17
|
+
build: "image-builds",
|
|
18
|
+
service: "services",
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Follows build and service log streams and keeps only the last KEEP lines of each, in
|
|
22
|
+
* memory and in a small file in the state directory. The platform serves a build log only
|
|
23
|
+
* while the build runs, and every connection replays it from the start, so a dropped
|
|
24
|
+
* stream is reopened and already-seen lines are skipped. When the build ends the stream
|
|
25
|
+
* closes and reopening answers "not found"; the kept lines stay readable. A service log
|
|
26
|
+
* covers its current container only (a restarted container starts empty), so services are followed from their start
|
|
27
|
+
* and for a while after anyone last asked.
|
|
28
|
+
*/
|
|
29
|
+
export class LogFollower {
|
|
30
|
+
api;
|
|
31
|
+
dir;
|
|
32
|
+
logs = new Map();
|
|
33
|
+
controller = new AbortController();
|
|
34
|
+
/** Backoff between reconnects; tests shorten it. */
|
|
35
|
+
retryMs = [1000, 2000, 5000, 10000, 20000];
|
|
36
|
+
/** Reopen a stream that sent nothing for this long (a silent proxy timeout). */
|
|
37
|
+
idleMs = 5 * 60 * 1000;
|
|
38
|
+
/** How long to follow after the last interest: builds until they end, services briefly. */
|
|
39
|
+
followMs = {
|
|
40
|
+
build: 2 * 60 * 60 * 1000,
|
|
41
|
+
service: 15 * 60 * 1000,
|
|
42
|
+
};
|
|
43
|
+
constructor(api, dir = join(api.config.stateDir, "logs")) {
|
|
44
|
+
this.api = api;
|
|
45
|
+
this.dir = dir;
|
|
46
|
+
}
|
|
47
|
+
/** Start following if nobody is; cheap to call on every poll. */
|
|
48
|
+
follow(id, kind = "build") {
|
|
49
|
+
if (!ID.test(id) || this.controller.signal.aborted)
|
|
50
|
+
return;
|
|
51
|
+
let log = this.logs.get(`${kind}:${id}`);
|
|
52
|
+
if (log)
|
|
53
|
+
log.until = Date.now() + this.followMs[kind];
|
|
54
|
+
if (log?.reader || (kind === "build" && log?.state === "finished"))
|
|
55
|
+
return;
|
|
56
|
+
if (!log) {
|
|
57
|
+
log = {
|
|
58
|
+
kind,
|
|
59
|
+
until: Date.now() + this.followMs[kind],
|
|
60
|
+
lines: [],
|
|
61
|
+
restarts: [],
|
|
62
|
+
state: "live",
|
|
63
|
+
saved: 0,
|
|
64
|
+
};
|
|
65
|
+
this.logs.set(`${kind}:${id}`, log);
|
|
66
|
+
this.prune();
|
|
67
|
+
}
|
|
68
|
+
log.state = "live";
|
|
69
|
+
log.error = undefined;
|
|
70
|
+
const current = log;
|
|
71
|
+
current.reader = this.read(id, current).finally(() => {
|
|
72
|
+
current.reader = undefined;
|
|
73
|
+
if (current.state === "live")
|
|
74
|
+
current.state = current.lines.length ? "finished" : "unavailable";
|
|
75
|
+
return this.save(id, current, true);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** Last `count` lines, following first if the build or service may still be running. */
|
|
79
|
+
async tail(id, kind = "build", count = 20, waitMs = kind === "build" ? 8000 : 1500) {
|
|
80
|
+
if (!ID.test(id))
|
|
81
|
+
throw new ToolError("invalid_input", `Invalid ${kind} ID.`);
|
|
82
|
+
let log = this.logs.get(`${kind}:${id}`) ?? (await this.load(id, kind));
|
|
83
|
+
if (log?.reader || kind === "service" || log?.state !== "finished") {
|
|
84
|
+
this.follow(id, kind);
|
|
85
|
+
log = this.logs.get(`${kind}:${id}`);
|
|
86
|
+
// A new connection replays the build log so far, in bursts over a few seconds:
|
|
87
|
+
// wait until it has been quiet for a second.
|
|
88
|
+
const deadline = Date.now() + waitMs;
|
|
89
|
+
while (log.reader &&
|
|
90
|
+
Date.now() < deadline &&
|
|
91
|
+
!(log.received && Date.now() - Date.parse(log.received) >= 1000))
|
|
92
|
+
await delay(100);
|
|
93
|
+
}
|
|
94
|
+
return this.view(id, log, count);
|
|
95
|
+
}
|
|
96
|
+
/** Kept lines without contacting the platform. */
|
|
97
|
+
async peek(id, kind = "build", count = 20) {
|
|
98
|
+
const log = this.logs.get(`${kind}:${id}`) ?? (await this.load(id, kind));
|
|
99
|
+
return log && log.lines.length ? this.view(id, log, count) : undefined;
|
|
100
|
+
}
|
|
101
|
+
async close() {
|
|
102
|
+
this.controller.abort();
|
|
103
|
+
await Promise.allSettled([...this.logs.values()].map((log) => log.reader));
|
|
104
|
+
}
|
|
105
|
+
/** New containers seen starting while this service was followed. */
|
|
106
|
+
restarts(id) {
|
|
107
|
+
return this.logs.get(`service:${id}`)?.restarts ?? [];
|
|
108
|
+
}
|
|
109
|
+
view(id, log, count) {
|
|
110
|
+
const service = log.kind === "service";
|
|
111
|
+
const restarts = log.restarts.length;
|
|
112
|
+
return {
|
|
113
|
+
kind: log.kind,
|
|
114
|
+
id,
|
|
115
|
+
state: log.state,
|
|
116
|
+
lines: log.lines.slice(-Math.max(1, Math.min(KEEP, count))),
|
|
117
|
+
last_received_at: log.received,
|
|
118
|
+
...(restarts
|
|
119
|
+
? { restarts_seen: restarts, restarted_at: log.restarts }
|
|
120
|
+
: {}),
|
|
121
|
+
message: log.error ??
|
|
122
|
+
(restarts
|
|
123
|
+
? `The container restarted ${restarts} time${restarts > 1 ? "s" : ""} while the MCP followed it (last at ${log.restarts.at(-1)}). Lines above each "--- new container" marker come from the container before it; its last lines usually show why it stopped.`
|
|
124
|
+
: undefined) ??
|
|
125
|
+
(log.state === "unavailable"
|
|
126
|
+
? service
|
|
127
|
+
? "No log: the service is not running, or its current container has printed nothing (a container that keeps crashing has no current log)."
|
|
128
|
+
: "No log: the build is not running and this MCP did not follow it while it ran. Constructor serves build logs only during the build."
|
|
129
|
+
: log.state === "finished"
|
|
130
|
+
? service
|
|
131
|
+
? "No longer following; these are the last lines kept."
|
|
132
|
+
: "The build is no longer running; these are the last lines it wrote."
|
|
133
|
+
: service
|
|
134
|
+
? "Following the log of the service's current container. No lines can mean the container keeps restarting (crash loop) or has printed nothing yet."
|
|
135
|
+
: undefined),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
async read(id, log) {
|
|
139
|
+
const signal = this.controller.signal;
|
|
140
|
+
for (let failures = 0;;) {
|
|
141
|
+
if (signal.aborted || Date.now() > log.until)
|
|
142
|
+
return;
|
|
143
|
+
const connection = new AbortController();
|
|
144
|
+
const stop = () => connection.abort();
|
|
145
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
146
|
+
let idle;
|
|
147
|
+
const watch = () => {
|
|
148
|
+
clearTimeout(idle);
|
|
149
|
+
idle = setTimeout(stop, this.idleMs);
|
|
150
|
+
};
|
|
151
|
+
try {
|
|
152
|
+
watch();
|
|
153
|
+
const response = await this.api.stream(`/${PATH[log.kind]}/${segment(id)}/logs`, connection.signal);
|
|
154
|
+
if (response.status === 404 || response.status === 421) {
|
|
155
|
+
// Not running (the build is over); what was kept is final.
|
|
156
|
+
await response.body?.cancel();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!response.ok || !response.body) {
|
|
160
|
+
await response.body?.cancel();
|
|
161
|
+
if (response.status < 500) {
|
|
162
|
+
log.error = `Constructor answered HTTP ${response.status} for this build log.`;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
throw new ToolError("connection_error", "");
|
|
166
|
+
}
|
|
167
|
+
failures = 0;
|
|
168
|
+
log.fresh = true;
|
|
169
|
+
log.pending = [];
|
|
170
|
+
let partial = "";
|
|
171
|
+
const decoder = new TextDecoder();
|
|
172
|
+
for await (const chunk of response.body) {
|
|
173
|
+
watch();
|
|
174
|
+
partial += decoder.decode(chunk, { stream: true });
|
|
175
|
+
const parts = partial.split(/\r?\n/);
|
|
176
|
+
partial = parts.pop().slice(-WIDTH * 4);
|
|
177
|
+
for (const part of parts)
|
|
178
|
+
this.add(log, part);
|
|
179
|
+
await this.save(id, log);
|
|
180
|
+
}
|
|
181
|
+
if (partial)
|
|
182
|
+
this.add(log, partial);
|
|
183
|
+
// Held lines of a connection that never showed a stamped line are a replay or a
|
|
184
|
+
// container that has not started yet; the next connection decides.
|
|
185
|
+
log.pending = [];
|
|
186
|
+
// A closed stream means the build ended or a proxy cut it; reopening tells which.
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
if (signal.aborted)
|
|
190
|
+
return;
|
|
191
|
+
if (err instanceof ToolError && err.code === "needs_auth") {
|
|
192
|
+
log.error =
|
|
193
|
+
"The token expired while following this build log; the lines below may be incomplete.";
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (++failures > 8) {
|
|
197
|
+
log.error =
|
|
198
|
+
"The build log connection kept failing; the lines below may be incomplete.";
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
clearTimeout(idle);
|
|
204
|
+
signal.removeEventListener("abort", stop);
|
|
205
|
+
}
|
|
206
|
+
await delay(this.retryMs[Math.min(failures, this.retryMs.length - 1)], undefined, { signal }).catch(() => { });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
add(log, raw) {
|
|
210
|
+
let line = raw.replace(ANSI, "");
|
|
211
|
+
// Server-sent-event framing, if any: keep data, drop fields and comments.
|
|
212
|
+
if (/^(event|id|retry):/.test(line) || line.startsWith(":"))
|
|
213
|
+
return;
|
|
214
|
+
if (line.startsWith("data:"))
|
|
215
|
+
line = line.slice(5).replace(/^ /, "");
|
|
216
|
+
line = line.trimEnd();
|
|
217
|
+
if (!line)
|
|
218
|
+
return;
|
|
219
|
+
const stamp = STAMP.exec(line)?.[1];
|
|
220
|
+
if (!stamp) {
|
|
221
|
+
if (log.kind === "service" && WAITING.test(line) && log.last) {
|
|
222
|
+
// After a container's own lines, the platform's waiting line means a new container,
|
|
223
|
+
// whether it arrives on this connection or opens the next one.
|
|
224
|
+
if (log.fresh)
|
|
225
|
+
return void log.pending?.push(line);
|
|
226
|
+
this.restarted(log, new Date().toISOString());
|
|
227
|
+
log.fresh = true;
|
|
228
|
+
log.pending = [line];
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (log.fresh && log.last)
|
|
232
|
+
return void log.pending?.push(line);
|
|
233
|
+
return this.keep(log, line);
|
|
234
|
+
}
|
|
235
|
+
if (log.fresh) {
|
|
236
|
+
log.fresh = false;
|
|
237
|
+
const held = log.pending ?? [];
|
|
238
|
+
log.pending = [];
|
|
239
|
+
// A replay starts with lines already kept (its held lines were kept too); anything
|
|
240
|
+
// newer on a fresh connection comes from a new container.
|
|
241
|
+
if (!log.last || stamp > log.last) {
|
|
242
|
+
if (log.kind === "service" && log.last)
|
|
243
|
+
this.restarted(log, stamp);
|
|
244
|
+
log.restarting = false;
|
|
245
|
+
for (const h of held)
|
|
246
|
+
this.keep(log, h);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (log.last && stamp <= log.last)
|
|
250
|
+
return; // replayed after a reconnect
|
|
251
|
+
log.last = stamp;
|
|
252
|
+
// A line that is only a timestamp carries nothing to show.
|
|
253
|
+
if (line.length > stamp.length)
|
|
254
|
+
this.keep(log, line);
|
|
255
|
+
}
|
|
256
|
+
/** Marks a new container in the kept lines. */
|
|
257
|
+
restarted(log, at) {
|
|
258
|
+
if (log.restarting)
|
|
259
|
+
return;
|
|
260
|
+
log.restarting = true;
|
|
261
|
+
log.restarts.push(at);
|
|
262
|
+
if (log.restarts.length > RESTARTS)
|
|
263
|
+
log.restarts.shift();
|
|
264
|
+
this.keep(log, `--- new container (restart ${log.restarts.length}) ---`);
|
|
265
|
+
}
|
|
266
|
+
keep(log, line) {
|
|
267
|
+
log.received = new Date().toISOString();
|
|
268
|
+
if (line === log.previous && log.lines.length) {
|
|
269
|
+
log.repeats = (log.repeats ?? 1) + 1;
|
|
270
|
+
log.lines[log.lines.length - 1] = `${line} (×${log.repeats})`;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
log.previous = line;
|
|
274
|
+
log.repeats = 1;
|
|
275
|
+
log.lines.push(line.length > WIDTH ? line.slice(0, WIDTH) + "…" : line);
|
|
276
|
+
if (log.lines.length > KEEP)
|
|
277
|
+
log.lines.shift();
|
|
278
|
+
}
|
|
279
|
+
/** At most every few seconds while streaming, and once at the end. */
|
|
280
|
+
async save(id, log, force = false) {
|
|
281
|
+
if (!force && Date.now() - log.saved < 5000)
|
|
282
|
+
return;
|
|
283
|
+
log.saved = Date.now();
|
|
284
|
+
try {
|
|
285
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
286
|
+
await writeFile(join(this.dir, `${log.kind}-${id}.json`), JSON.stringify({
|
|
287
|
+
state: log.reader && !force ? "live" : log.state,
|
|
288
|
+
lines: log.lines,
|
|
289
|
+
last: log.last,
|
|
290
|
+
restarts: log.restarts,
|
|
291
|
+
received: log.received,
|
|
292
|
+
error: log.error,
|
|
293
|
+
}), { mode: 0o600 });
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
/* Logs are a convenience; never fail a deployment over them. */
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async load(id, kind) {
|
|
300
|
+
try {
|
|
301
|
+
const kept = JSON.parse(await readFile(join(this.dir, `${kind}-${id}.json`), "utf8"));
|
|
302
|
+
// A "live" file was written by another process, which may have stopped.
|
|
303
|
+
const log = {
|
|
304
|
+
kind,
|
|
305
|
+
until: 0,
|
|
306
|
+
lines: Array.isArray(kept.lines) ? kept.lines.slice(-KEEP) : [],
|
|
307
|
+
state: kept.state === "finished" ? "finished" : "unavailable",
|
|
308
|
+
last: kept.last,
|
|
309
|
+
restarts: Array.isArray(kept.restarts) ? kept.restarts : [],
|
|
310
|
+
received: kept.received,
|
|
311
|
+
error: kept.error,
|
|
312
|
+
saved: 0,
|
|
313
|
+
};
|
|
314
|
+
this.logs.set(`${kind}:${id}`, log);
|
|
315
|
+
return log;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/** Forget the oldest finished builds; their files remain. */
|
|
322
|
+
prune() {
|
|
323
|
+
for (const [id, log] of this.logs) {
|
|
324
|
+
if (this.logs.size <= 100)
|
|
325
|
+
return;
|
|
326
|
+
if (!log.reader)
|
|
327
|
+
this.logs.delete(id);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { DeploymentEngine } from "./deployment.js";
|
|
2
|
+
export declare const paused: Set<string>;
|
|
3
|
+
/** Owns execution. The agent submits intent and observes results, never engine steps. */
|
|
4
|
+
export declare class DeploymentRuntime {
|
|
5
|
+
readonly engine: DeploymentEngine;
|
|
6
|
+
private readonly pollMilliseconds;
|
|
7
|
+
private active;
|
|
8
|
+
private closed;
|
|
9
|
+
constructor(engine: DeploymentEngine, pollMilliseconds?: number);
|
|
10
|
+
recover(): Promise<void>;
|
|
11
|
+
close(): Promise<void>;
|
|
12
|
+
deploy(raw: unknown): Promise<any>;
|
|
13
|
+
update(raw: unknown): Promise<any>;
|
|
14
|
+
buildImage(raw: unknown): Promise<any>;
|
|
15
|
+
lifecycle(kind: "start" | "stop", raw: unknown): Promise<any>;
|
|
16
|
+
get(raw: unknown): Promise<any>;
|
|
17
|
+
private execute;
|
|
18
|
+
private pause;
|
|
19
|
+
private wake;
|
|
20
|
+
private run;
|
|
21
|
+
private snapshot;
|
|
22
|
+
private wait;
|
|
23
|
+
}
|