@edgehero/pi-dispatch 4.1.0 → 4.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/.env.example +6 -4
- package/package.json +4 -2
- package/src/azure-host.mjs +28 -1
- package/src/azure-prompt.mjs +26 -3
- package/src/config.mjs +2 -2
- package/src/doctor.mjs +185 -13
- package/src/egress-allowlist.mjs +81 -0
- package/src/env-allowlist.mjs +8 -1
- package/src/forgejo-host.mjs +33 -1
- package/src/forgejo-prompt.mjs +26 -3
- package/src/github-host.mjs +21 -1
- package/src/github-prompt.mjs +26 -3
- package/src/gitlab-host.mjs +24 -1
- package/src/gitlab-prompt.mjs +26 -3
- package/src/host-pi.mjs +8 -5
- package/src/image-preflight.mjs +35 -1
- package/src/import-pi.mjs +113 -8
- package/src/mcp-config.mjs +466 -0
- package/src/mcp-preflight.mjs +262 -0
- package/src/model-catalog.mjs +2 -0
- package/src/model-endpoints.mjs +8 -3
- package/src/models-json.mjs +65 -53
- package/src/outbox.mjs +7 -1
- package/src/parked.mjs +18 -0
- package/src/prepare.mjs +12 -4
- package/src/processor.mjs +81 -4
- package/src/provider-steering.mjs +1 -0
- package/src/queue.mjs +10 -2
- package/src/reserved-env.mjs +2 -0
- package/src/run-container.mjs +6 -0
- package/src/run-history.mjs +29 -2
- package/src/schedules.mjs +1 -1
- package/src/session-store.mjs +45 -2
- package/src/start.mjs +59 -1
- package/src/triggers-file.mjs +7 -0
- package/src/triggers.mjs +152 -7
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pickup's MCP question (REQ-MCP-OPERATOR-SERVERS, DES-MCP-EGRESS-PREFLIGHT): can this job use the overlay MCP
|
|
3
|
+
* servers its trigger names (`run.mcp`)? Answered before anything is spent (CONST-BUDGET-BEFORE-TOKENS), from the
|
|
4
|
+
* overlay's `mcp.json` read once for the job, and the answer carries the per-job document `prepare` writes to
|
|
5
|
+
* `/job/mcp.json`, so the servers the job connects are exactly the servers this check passed.
|
|
6
|
+
*
|
|
7
|
+
* One of:
|
|
8
|
+
* { ok: true, document } `document` null when the job names no server
|
|
9
|
+
* { refused, name, why } a determinate refusal, returned (CONST-RETRY-INFRA-ONLY): `refused` is the reason
|
|
10
|
+
* { unavailable: code } the overlay could not be read just now, an errno that may pass: retried
|
|
11
|
+
*
|
|
12
|
+
* The refusals, in the order they are asked:
|
|
13
|
+
* mcp-list-invalid the job's own list is not one the loader writes (queue data from another version, or a
|
|
14
|
+
* hand-added job);
|
|
15
|
+
* mcp-config-invalid the overlay's mcp.json cannot be read whole (or there is no overlay, or no file, when the
|
|
16
|
+
* job names a server other than `forge`);
|
|
17
|
+
* mcp-server-unknown a name the file does not offer, offers disabled, or refuses (doctor names why); or
|
|
18
|
+
* `forge` on a job that is not a github or gitlab one;
|
|
19
|
+
* mcp-secret-reserved a reference to a variable this deployment writes or pi reads for the job itself (a
|
|
20
|
+
* credential the job spends with, `providerKeyNames`; a minted forge token; the egress or
|
|
21
|
+
* runner variables): a server must never be handed the job's own credentials. Asked of
|
|
22
|
+
* every reference before the next rule;
|
|
23
|
+
* mcp-secret-unmapped a reference the job will not have: not a `run.secrets` name, and not a PI_FORWARD_ENV
|
|
24
|
+
* name the worker's environment holds a value for;
|
|
25
|
+
* mcp-excluded the trigger's own run.excludeTools leaves a server no way in that it had: its tools and
|
|
26
|
+
* its resources reachable only through tools it removed (`mcpRouteProblem`);
|
|
27
|
+
* mcp-egress-unreachable with the egress policy on, a streamable HTTP server the proxy would not let the job reach,
|
|
28
|
+
* or the forge server's API host (api.github.com, or the configured GitLab's) unlisted;
|
|
29
|
+
* mcp-package-runner with the egress policy on, a package runner whose registry the allowlist does not hold.
|
|
30
|
+
*
|
|
31
|
+
* `forge` (DES-FORGE-MCP) is not read from the overlay: its entry is the worker's (`forgeMcpServer`), so a job naming
|
|
32
|
+
* only it needs no mcp.json at all, and it is exempt from the two secret rules, its one reference being the forge token
|
|
33
|
+
* the worker itself writes into the container for this job. The overlay's top-level switches still govern it when the
|
|
34
|
+
* file is there, so a file that is there and cannot be read refuses it like any other server.
|
|
35
|
+
*/
|
|
36
|
+
import { allowlistEntryFor, egressRefusalFor, packageRunnerHosts } from "./egress-allowlist.mjs";
|
|
37
|
+
import { FORGE_MCP_KINDS, FORGE_MCP_SERVER_NAME, forgeMcpEgressUrl, forgeMcpServer, gitlabUrlProblem, jobMcpDocument, MCP_SERVER_NAME_RE, readOverlayMcp } from "./mcp-config.mjs";
|
|
38
|
+
import { MCP_SERVERS_MAX, mcpServerToolPattern } from "./triggers.mjs";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Errnos a read of the overlay may pass on a second try. Restated from model-catalog.mjs `OVERLAY_TRANSIENT_READ_CODES`
|
|
42
|
+
* rather than imported, since that module loads pi's catalog; mcp-preflight.test.mjs holds the two equal.
|
|
43
|
+
*/
|
|
44
|
+
export const MCP_TRANSIENT_READ_CODES = new Set(["EIO", "EAGAIN", "EMFILE", "ENFILE"]);
|
|
45
|
+
|
|
46
|
+
/** A variable reference in a pi config value, `$NAME` or `${NAME}`, anywhere in the string (pi's own grammar). */
|
|
47
|
+
const CONFIG_REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The credentials a job is set up to spend with, as variable names: each builtin key variable of the providers it may
|
|
51
|
+
* call (`providers`: its main provider and the ones on its model list), and every variable the overlay models.json
|
|
52
|
+
* references anywhere (a custom provider's `apiKey` in any form, its headers, a model's or an override's headers),
|
|
53
|
+
* since pi reads each of them for model calls. A builtin provider the job cannot call (its model list does not name
|
|
54
|
+
* it, `jobProviders`) is not counted: its variable is one the operator may hand a server on purpose (`HF_TOKEN` for a
|
|
55
|
+
* Hugging Face MCP server), as `run.secrets` may. A custom provider whose key is a `!command` names no variable this
|
|
56
|
+
* can see: the operator who writes both files is the one who can avoid that pairing.
|
|
57
|
+
*/
|
|
58
|
+
export function providerKeyNames({ providers = [], candidatesOf = () => [], overlayModels = null } = {}) {
|
|
59
|
+
const names = new Set();
|
|
60
|
+
for (const provider of providers) for (const name of candidatesOf(provider) ?? []) if (typeof name === "string") names.add(name);
|
|
61
|
+
const walk = (value) => {
|
|
62
|
+
if (typeof value === "string") for (const match of value.matchAll(CONFIG_REF_RE)) names.add(match[1] ?? match[2]);
|
|
63
|
+
else if (value !== null && typeof value === "object") Object.values(value).forEach(walk);
|
|
64
|
+
};
|
|
65
|
+
walk(overlayModels);
|
|
66
|
+
return names;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The providers a job may call: its own, and each on its model list (the trigger's, else the deployment's), which the
|
|
71
|
+
* runner's model guard holds every call to. With no list there is no guard, and the job may call any provider whose
|
|
72
|
+
* key its container holds (a pi child of its own, `--provider` and all), so every builtin provider (`everyProvider`)
|
|
73
|
+
* counts. An operator who hands a server a provider's variable on purpose (`HF_TOKEN`) gives that trigger a list.
|
|
74
|
+
*/
|
|
75
|
+
export function jobProviders(job, { defaultProvider = null, allowedModels = null, everyProvider = [] } = {}) {
|
|
76
|
+
const list = Array.isArray(job?.models) ? job.models : Array.isArray(allowedModels) ? allowedModels : null;
|
|
77
|
+
const listed = list === null ? everyProvider : list.map((ref) => (typeof ref === "string" ? ref.split("/")[0] : ref?.provider));
|
|
78
|
+
return [...new Set([job?.provider ?? defaultProvider, ...listed].filter((p) => typeof p === "string" && p !== ""))];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The check, bound to this deployment. `reserved(job)` is the set of variable names the job's own container holds for
|
|
83
|
+
* itself; `hostEnv` is the worker's environment, which decides whether a PI_FORWARD_ENV name reaches the job at all;
|
|
84
|
+
* `allowlist()` and `endpoints()` are read only when a server needs them, and `allowlist()` throws what its read threw.
|
|
85
|
+
* `gitlabUrl` is the worker's configured GitLab URL (null when it serves no GitLab), which a gitlab job's forge server
|
|
86
|
+
* calls.
|
|
87
|
+
*/
|
|
88
|
+
export function makeMcpCheck({ globalPiDir, forwardEnv = [], hostEnv = {}, egress = false, reserved = () => new Set(), allowlist = () => [], endpoints = () => [], read = readOverlayMcp, gitlabUrl = null }) {
|
|
89
|
+
return (job) => {
|
|
90
|
+
const listed = job?.mcp;
|
|
91
|
+
if (listed === undefined) return { ok: true, document: null };
|
|
92
|
+
// The loader's shape, asked again: queue data is not always the current receiver's (an older or newer service,
|
|
93
|
+
// a hand-added job, a stored repeatable), and a list that is not one must neither pass as "none" nor reach the
|
|
94
|
+
// public comment. The name is withheld from the comment when it is not a valid name.
|
|
95
|
+
if (!Array.isArray(listed) || listed.length === 0 || listed.length > MCP_SERVERS_MAX || new Set(listed).size !== listed.length || listed.some((name) => typeof name !== "string" || !MCP_SERVER_NAME_RE.test(name))) {
|
|
96
|
+
return { refused: "mcp-list-invalid", name: null, why: "the job's run.mcp is not a list of server names the loader accepts" };
|
|
97
|
+
}
|
|
98
|
+
const names = listed;
|
|
99
|
+
const overlayNames = names.filter((name) => name !== FORGE_MCP_SERVER_NAME);
|
|
100
|
+
let parsed;
|
|
101
|
+
try {
|
|
102
|
+
parsed = read(globalPiDir || "");
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (!error?.mcpConfig && MCP_TRANSIENT_READ_CODES.has(error?.code)) return { unavailable: error.code };
|
|
105
|
+
return { refused: "mcp-config-invalid", name: null, why: error?.mcpConfig ? "the overlay's mcp.json cannot be read whole" : `the overlay's mcp.json cannot be read (${error?.code ?? "unreadable"})` };
|
|
106
|
+
}
|
|
107
|
+
if (parsed === null) {
|
|
108
|
+
if (overlayNames.length > 0) return { refused: "mcp-config-invalid", name: null, why: globalPiDir ? "the overlay holds no mcp.json" : "this deployment has no overlay (PI_GLOBAL_PI_DIR)" };
|
|
109
|
+
// Only `forge`: the overlay's defaults, as an empty file reads.
|
|
110
|
+
parsed = { autoEnableCodemode: true, codemodeModels: true, servers: {}, refused: [] };
|
|
111
|
+
}
|
|
112
|
+
const refusedHere = new Map(parsed.refused.map((r) => [r.name, r.problem]));
|
|
113
|
+
let servers = parsed.servers;
|
|
114
|
+
if (overlayNames.length !== names.length) {
|
|
115
|
+
// The kind is queue data like the list: a job from elsewhere naming `forge` on a kind with no forge server (or
|
|
116
|
+
// no forge token) is refused here, never handed another forge's entry.
|
|
117
|
+
if (!FORGE_MCP_KINDS.includes(job.kind)) return { refused: "mcp-server-unknown", name: FORGE_MCP_SERVER_NAME, why: "the forge's own server is offered to a github or gitlab job only" };
|
|
118
|
+
if (job.kind === "gitlab" && (typeof gitlabUrl !== "string" || gitlabUrl === "")) return { refused: "mcp-server-unknown", name: FORGE_MCP_SERVER_NAME, why: "this worker has no GitLab URL configured" };
|
|
119
|
+
const urlProblem = job.kind === "gitlab" ? gitlabUrlProblem(gitlabUrl) : null;
|
|
120
|
+
if (urlProblem !== null) return { refused: "mcp-server-unknown", name: FORGE_MCP_SERVER_NAME, why: `GITLAB_URL cannot be handed to the forge server: ${urlProblem}` };
|
|
121
|
+
servers = { ...parsed.servers, [FORGE_MCP_SERVER_NAME]: forgeMcpServer(job.kind, { gitlabUrl }) };
|
|
122
|
+
}
|
|
123
|
+
for (const name of overlayNames) {
|
|
124
|
+
const server = Object.hasOwn(servers, name) ? servers[name] : undefined;
|
|
125
|
+
if (!server) return { refused: "mcp-server-unknown", name, why: refusedHere.has(name) ? "the overlay's mcp.json refuses it" : "the overlay's mcp.json does not offer it" };
|
|
126
|
+
if (server.enabled === false) return { refused: "mcp-server-unknown", name, why: "the overlay's mcp.json turns it off" };
|
|
127
|
+
}
|
|
128
|
+
// A forwarded name reaches the job only when the worker holds a value for it (env-allowlist.mjs buildContainerEnv).
|
|
129
|
+
const forwarded = forwardEnv.filter((ref) => typeof hostEnv[ref] === "string" && hostEnv[ref] !== "");
|
|
130
|
+
const mapped = new Set([...Object.keys(job.secrets ?? {}), ...forwarded]);
|
|
131
|
+
let own;
|
|
132
|
+
try {
|
|
133
|
+
own = reserved(job);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
// The overlay models.json read inside `reserved` that may pass on a retry: without it, a custom provider's
|
|
136
|
+
// credential would drop out of the set for this one pickup.
|
|
137
|
+
if (MCP_TRANSIENT_READ_CODES.has(error?.code)) return { unavailable: error.code };
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
// The forge server is exempt from both rules: its one reference is the token the worker mints for this job and
|
|
141
|
+
// writes under exactly that name, which is the reason every other server is refused it.
|
|
142
|
+
for (const name of overlayNames) {
|
|
143
|
+
for (const ref of servers[name].refs) {
|
|
144
|
+
if (own.has(ref)) return { refused: "mcp-secret-reserved", name, why: `it references ${ref}, which this deployment uses for the job's own credentials` };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const name of overlayNames) {
|
|
148
|
+
for (const ref of servers[name].refs) {
|
|
149
|
+
if (!mapped.has(ref)) return { refused: "mcp-secret-unmapped", name, why: `it references ${ref}, which the trigger does not pass (run.secrets, or a PI_FORWARD_ENV name the worker holds a value for)` };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const cut = mcpRouteProblem(servers, names, new Set(Array.isArray(job.excludeTools) ? job.excludeTools : []), { autoEnableCodemode: parsed.autoEnableCodemode });
|
|
153
|
+
if (cut !== null) return { refused: "mcp-excluded", name: cut.name, why: cut.why };
|
|
154
|
+
if (egress) {
|
|
155
|
+
let list = null;
|
|
156
|
+
let declared = null;
|
|
157
|
+
// The allowlist read that may pass on a retry is retried, like the overlay's; any other failure refuses, since
|
|
158
|
+
// without the list no server outside a declared endpoint can be judged reachable.
|
|
159
|
+
const readList = () => {
|
|
160
|
+
try {
|
|
161
|
+
return allowlist();
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (MCP_TRANSIENT_READ_CODES.has(error?.code)) throw Object.assign(new Error("transient"), { transient: error.code });
|
|
164
|
+
throw Object.assign(new Error("unreadable"), { unreadable: error?.code ?? "unreadable" });
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
try {
|
|
168
|
+
for (const name of names) {
|
|
169
|
+
const server = servers[name];
|
|
170
|
+
if (name === FORGE_MCP_SERVER_NAME) {
|
|
171
|
+
// Its API host, judged as a streamable HTTP server's url is: the proxy sees the same CONNECT either way.
|
|
172
|
+
list ??= readList();
|
|
173
|
+
declared ??= endpoints();
|
|
174
|
+
const refusal = egressRefusalFor(forgeMcpEgressUrl(job.kind, { gitlabUrl }), { allowlist: list, endpoints: declared });
|
|
175
|
+
if (refusal !== null) return { refused: "mcp-egress-unreachable", name, why: refusal };
|
|
176
|
+
} else if (server.transport === "http") {
|
|
177
|
+
list ??= readList();
|
|
178
|
+
declared ??= endpoints();
|
|
179
|
+
const refusal = egressRefusalFor(server.url, { allowlist: list, endpoints: declared });
|
|
180
|
+
if (refusal !== null) return { refused: "mcp-egress-unreachable", name, why: refusal };
|
|
181
|
+
} else if (server.packageRunner) {
|
|
182
|
+
list ??= readList();
|
|
183
|
+
const missing = packageRunnerHosts(server.command).filter((host) => allowlistEntryFor(list, host) === null);
|
|
184
|
+
if (missing.length > 0) return { refused: "mcp-package-runner", name, why: `it fetches its server from ${missing.join(" and ")}, which egress-allowlist.conf does not hold` };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error?.transient) return { unavailable: error.transient };
|
|
189
|
+
if (error?.unreadable) return { refused: "mcp-egress-unreachable", name: null, why: `egress-allowlist.conf cannot be read (${error.unreadable})` };
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { ok: true, document: jobMcpDocument({ ...parsed, servers }, names) };
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A server's exposures as pi reads them (`getMcpToolExposure`): an exact tool name first, then the patterns in order,
|
|
199
|
+
* the first that matches winning, else the server's own. So a pattern after `*` never applies, and `*` replaces the
|
|
200
|
+
* default; any exact name and any earlier pattern may still match a tool.
|
|
201
|
+
*/
|
|
202
|
+
function exposuresOf(server) {
|
|
203
|
+
const own = server.exposure ?? "codemode";
|
|
204
|
+
const entries = Object.entries(server.toolExposure ?? {}).filter(([key]) => !key.startsWith("dispatch_"));
|
|
205
|
+
const tools = entries.filter(([key]) => !key.includes("*")).map(([, value]) => value);
|
|
206
|
+
let base = own;
|
|
207
|
+
for (const [key, value] of entries.filter(([key]) => key.includes("*"))) {
|
|
208
|
+
if (key === "*") {
|
|
209
|
+
base = value;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
tools.push(value);
|
|
213
|
+
}
|
|
214
|
+
return { own, tools: [base, ...tools] };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Whether each named server keeps a way in through the tools `excluded` leaves, as pi 1.1.0 routes them: a `direct` tool
|
|
219
|
+
* needs nothing; a `codemode` or `deferred` tool is reached by whichever discovery tool is active, since codemode calls
|
|
220
|
+
* both kinds and tool_search loads both, and pi activates codemode when some server has a codemode tool (and
|
|
221
|
+
* `autoEnableCodemode` is not false) and tool_search when some server has a deferred one; the resource tools take their
|
|
222
|
+
* server's own exposure, and a `hidden` server's resources are never offered. Judged only for what the trigger's
|
|
223
|
+
* exclusions take away: a server the overlay leaves no way in is the overlay's, not this trigger's to be refused for.
|
|
224
|
+
* Returns `{ name, why }` for the first server the exclusions cut off, or null.
|
|
225
|
+
*/
|
|
226
|
+
export function mcpRouteProblem(servers, names, excluded, { autoEnableCodemode = true } = {}) {
|
|
227
|
+
const routes = (removed) => {
|
|
228
|
+
const all = names.map((name) => exposuresOf(servers[name]));
|
|
229
|
+
const has = (kind) => all.some(({ own, tools }) => own === kind || tools.includes(kind));
|
|
230
|
+
const discovery = (!removed.has("codemode") && autoEnableCodemode !== false && has("codemode")) || (!removed.has("tool_search") && has("deferred"));
|
|
231
|
+
const reach = (exposure) => exposure === "direct" || ((exposure === "codemode" || exposure === "deferred") && discovery);
|
|
232
|
+
const widest = ["direct", "codemode", "deferred"].find((kind) => all.some(({ own }) => own === kind)) ?? "hidden";
|
|
233
|
+
return names.map((name, i) => {
|
|
234
|
+
const { own, tools } = all[i];
|
|
235
|
+
const toolRoute = !removed.has(mcpServerToolPattern(name)) && tools.some(reach);
|
|
236
|
+
// pi registers the resource tools once, with the widest exposure among the servers that offer resources
|
|
237
|
+
// and are not hidden (syncResourceTools), and read_mcp_resource reads any of them. Whether a server lists
|
|
238
|
+
// resources is unknown until it connects, so each one that is not hidden is assumed to.
|
|
239
|
+
const resourceRoute = !removed.has("read_mcp_resource") && own !== "hidden" && reach(widest);
|
|
240
|
+
return toolRoute || resourceRoute;
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
const before = routes(new Set());
|
|
244
|
+
const after = routes(excluded);
|
|
245
|
+
const i = names.findIndex((_, k) => before[k] && !after[k]);
|
|
246
|
+
return i < 0 ? null : { name: names[i], why: "run.excludeTools removes every tool that reaches it (its own, the discovery tools that reach them, and read_mcp_resource)" };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The refusal's public sentence, per reason. Fixed text: the comment posts on the issue, and `why` can name an
|
|
251
|
+
* internal host or a variable, which belong in the worker's log, not in a thread anyone can read.
|
|
252
|
+
*/
|
|
253
|
+
export const MCP_REFUSAL_COMMENT = Object.freeze({
|
|
254
|
+
"mcp-list-invalid": "the list this job carries is not one the triggers file's loader writes (a service in this deployment may be at another version)",
|
|
255
|
+
"mcp-config-invalid": "this deployment's list of MCP servers (mcp.json in the overlay) cannot be used",
|
|
256
|
+
"mcp-server-unknown": "this deployment does not offer that server",
|
|
257
|
+
"mcp-secret-reserved": "that server would be handed a credential this deployment keeps for the job itself",
|
|
258
|
+
"mcp-secret-unmapped": "a credential that server needs is not passed by this trigger",
|
|
259
|
+
"mcp-excluded": "this trigger's run.excludeTools removes every way the job could reach that server",
|
|
260
|
+
"mcp-egress-unreachable": "the egress policy does not let a job reach that server",
|
|
261
|
+
"mcp-package-runner": "the egress policy does not let a job reach the registry that server is fetched from",
|
|
262
|
+
});
|
package/src/model-catalog.mjs
CHANGED
|
@@ -125,6 +125,8 @@ export function overlayProviderProblem(config, providerId) {
|
|
|
125
125
|
if (!api) return "no-api";
|
|
126
126
|
const baseUrl = def.baseUrl ?? config.baseUrl ?? defaults?.baseUrl;
|
|
127
127
|
if (!baseUrl) return "no-baseUrl";
|
|
128
|
+
// pi 1.2.0's schema refuses both first (a token count must be above zero), so a file reaching here holds none; kept
|
|
129
|
+
// because provider-composer.js still checks them, and the mirror follows that file, not the schema in front of it.
|
|
128
130
|
if (def.contextWindow !== undefined && def.contextWindow <= 0) return "contextWindow";
|
|
129
131
|
if (def.maxTokens !== undefined && def.maxTokens <= 0) return "maxTokens";
|
|
130
132
|
const composed = { id: def.id, api, baseUrl };
|
package/src/model-endpoints.mjs
CHANGED
|
@@ -363,7 +363,12 @@ export function modelEntryOf(models, provider, modelId) {
|
|
|
363
363
|
return { entry, defined, override: override !== null && typeof override === "object" ? override : null };
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
|
|
366
|
+
/**
|
|
367
|
+
* The declared endpoints a URL dials: those whose host AND port `baseUrlTarget` reads from it. The one match, for a
|
|
368
|
+
* model's baseUrl here, an MCP server's url at the pickup's egress check (`egressRefusalFor`) and doctor's endpoint
|
|
369
|
+
* probes alike, so the three cannot disagree on which endpoint a URL is.
|
|
370
|
+
*/
|
|
371
|
+
export function endpointsAt(baseUrl, endpoints) {
|
|
367
372
|
const target = baseUrlTarget(baseUrl);
|
|
368
373
|
if (target === null || !Array.isArray(endpoints)) return [];
|
|
369
374
|
return endpoints.filter((e) => e.host === target.host && e.port === target.port);
|
|
@@ -380,7 +385,7 @@ export function endpointsForModel({ models, provider, modelId, endpoints }) {
|
|
|
380
385
|
if (entry === null) return [];
|
|
381
386
|
const model = modelsOf(entry).find((m) => m.id === modelId);
|
|
382
387
|
const baseUrl = typeof model?.baseUrl === "string" ? model.baseUrl : entry.baseUrl;
|
|
383
|
-
return
|
|
388
|
+
return endpointsAt(baseUrl, endpoints);
|
|
384
389
|
}
|
|
385
390
|
|
|
386
391
|
/**
|
|
@@ -391,7 +396,7 @@ export function endpointsForModel({ models, provider, modelId, endpoints }) {
|
|
|
391
396
|
export function endpointsForProvider({ models, provider, endpoints }) {
|
|
392
397
|
const entry = providerOf(models, provider);
|
|
393
398
|
if (entry === null) return [];
|
|
394
|
-
return modelsOf(entry).map((m) => ({ modelId: m.id, endpoints:
|
|
399
|
+
return modelsOf(entry).map((m) => ({ modelId: m.id, endpoints: endpointsAt(typeof m.baseUrl === "string" ? m.baseUrl : entry.baseUrl, endpoints) }));
|
|
395
400
|
}
|
|
396
401
|
|
|
397
402
|
/**
|
package/src/models-json.mjs
CHANGED
|
@@ -121,8 +121,13 @@ const T = {
|
|
|
121
121
|
const opt = (check) => [check, true];
|
|
122
122
|
const req = (check) => [check, false];
|
|
123
123
|
|
|
124
|
-
// The schema, transcribed
|
|
125
|
-
//
|
|
124
|
+
// The schema, transcribed at the pin (1.2.0; worker/test/models-json.test.mjs holds each file's content hash). Since
|
|
125
|
+
// 1.2.0 model-config.js builds it from two pi-ai modules, `providers/model-schema.js` (cost, prompt cache, input
|
|
126
|
+
// limits, thinking map, modalities) and `providers/compat-schema.js` (compat), so all three are pinned.
|
|
127
|
+
//
|
|
128
|
+
// The change that matters in 1.2.0: `compat` was a UNION of three object schemas that each admit extra keys, so a value
|
|
129
|
+
// one api's schema refused passed through another (`supportsStore: "x"` loaded). It is now ONE object, the superset of
|
|
130
|
+
// five apis' properties, so every known compat key is type-checked wherever `compat` appears.
|
|
126
131
|
const PercentileCutoffs = T.obj({ p50: opt(T.num()), p75: opt(T.num()), p90: opt(T.num()), p99: opt(T.num()) });
|
|
127
132
|
const numOrStr = T.union(T.num(), T.str());
|
|
128
133
|
const OpenRouterRouting = T.obj({
|
|
@@ -141,70 +146,76 @@ const OpenRouterRouting = T.obj({
|
|
|
141
146
|
preferred_max_latency: opt(T.union(T.num(), PercentileCutoffs)),
|
|
142
147
|
});
|
|
143
148
|
const VercelGatewayRouting = T.obj({ only: opt(T.arr(T.str())), order: opt(T.arr(T.str())) });
|
|
149
|
+
// model-schema.js: `Partial(Record(Enum(levels), String | Null))`, which checks the seven level keys and admits others.
|
|
144
150
|
const ThinkingValue = T.union(T.str(), T.nul());
|
|
145
151
|
const ThinkingLevelMap = T.obj({ off: opt(ThinkingValue), minimal: opt(ThinkingValue), low: opt(ThinkingValue), medium: opt(ThinkingValue), high: opt(ThinkingValue), xhigh: opt(ThinkingValue), max: opt(ThinkingValue) });
|
|
146
152
|
// pi 1.0.2 (issue #587): a model's sampling parameters, and the same per thinking level, merged per call over them.
|
|
147
153
|
const SamplingParams = T.rec(T.unknown());
|
|
148
154
|
const SamplingParamsByThinkingLevel = T.obj({ off: opt(SamplingParams), minimal: opt(SamplingParams), low: opt(SamplingParams), medium: opt(SamplingParams), high: opt(SamplingParams), xhigh: opt(SamplingParams), max: opt(SamplingParams) });
|
|
149
|
-
const
|
|
150
|
-
const KwargVariable = T.obj({ $var: req(T.union(T.lit("thinking.enabled"), T.lit("thinking.effort"))), omitWhenOff: opt(T.bool()) });
|
|
151
|
-
const Kwarg = T.union(KwargScalar, KwargVariable);
|
|
155
|
+
const KwargValue = T.union(T.str(), T.num(), T.bool(), T.nul(), T.obj({ $var: req(T.union(T.lit("thinking.enabled"), T.lit("thinking.effort"), T.lit("thinking.budget"))), omitWhenOff: opt(T.bool()) }));
|
|
152
156
|
const affinity = T.union(T.lit("openai"), T.lit("openai-nosession"), T.lit("openrouter"));
|
|
153
|
-
const OpenAICompletionsCompat = T.obj({
|
|
154
|
-
supportsStore: opt(T.bool()),
|
|
155
|
-
supportsDeveloperRole: opt(T.bool()),
|
|
156
|
-
supportsReasoningEffort: opt(T.bool()),
|
|
157
|
-
supportsUsageInStreaming: opt(T.bool()),
|
|
158
|
-
supportsFinishReason: opt(T.bool()),
|
|
159
|
-
maxTokensField: opt(T.union(T.lit("max_completion_tokens"), T.lit("max_tokens"))),
|
|
160
|
-
requiresToolResultName: opt(T.bool()),
|
|
161
|
-
requiresAssistantAfterToolResult: opt(T.bool()),
|
|
162
|
-
requiresThinkingAsText: opt(T.bool()),
|
|
163
|
-
requiresReasoningContentOnAssistantMessages: opt(T.bool()),
|
|
164
|
-
thinkingFormat: opt(T.union(...["openai", "openrouter", "together", "baseten", "deepseek", "zai", "qwen", "chat-template", "qwen-chat-template", "string-thinking", "ant-ling"].map((x) => T.lit(x)))),
|
|
165
|
-
chatTemplateKwargs: opt(T.rec(Kwarg)),
|
|
166
|
-
chatTemplateArgs: opt(T.rec(Kwarg)),
|
|
167
|
-
cacheControlFormat: opt(T.lit("anthropic")),
|
|
168
|
-
openRouterRouting: opt(OpenRouterRouting),
|
|
169
|
-
vercelGatewayRouting: opt(VercelGatewayRouting),
|
|
170
|
-
supportsOpenAIGrammarTools: opt(T.bool()),
|
|
171
|
-
supportsStrictMode: opt(T.bool()),
|
|
172
|
-
sendSessionAffinityHeaders: opt(T.bool()),
|
|
173
|
-
sessionAffinityFormat: opt(affinity),
|
|
174
|
-
supportsLongCacheRetention: opt(T.bool()),
|
|
175
|
-
vllmPriority: opt(T.num()),
|
|
176
|
-
});
|
|
177
|
-
const OpenAIResponsesCompat = T.obj({
|
|
178
|
-
supportsDeveloperRole: opt(T.bool()),
|
|
179
|
-
sessionAffinityFormat: opt(affinity),
|
|
180
|
-
supportsLongCacheRetention: opt(T.bool()),
|
|
181
|
-
supportsStrictMode: opt(T.bool()),
|
|
182
|
-
supportsOpenAIGrammarTools: opt(T.bool()),
|
|
183
|
-
supportsMaxOutputTokens: opt(T.bool()),
|
|
184
|
-
});
|
|
185
157
|
const costRates = { input: req(T.num()), output: req(T.num()), cacheRead: req(T.num()), cacheWrite: req(T.num()) };
|
|
186
158
|
const ModelCostTier = T.obj({ inputTokensAbove: req(T.num()), ...costRates });
|
|
187
159
|
const ModelCost = T.obj({ ...costRates, tiers: opt(T.arr(ModelCostTier)) });
|
|
160
|
+
// `Partial(Record(Enum(["short", "long"]), Number > 0))`, like the thinking map.
|
|
188
161
|
const ModelPromptCache = T.obj({ short: opt(T.num({ exclusiveMinimum: 0 })), long: opt(T.num({ exclusiveMinimum: 0 })) });
|
|
189
162
|
const posInt = T.int({ minimum: 1 });
|
|
190
163
|
const ImageResize = T.obj({ maxWidth: opt(posInt), maxHeight: opt(posInt), maxBytes: opt(posInt), jpegQuality: opt(T.int({ minimum: 1, maximum: 100 })) });
|
|
191
164
|
const ModelInputLimits = T.obj({ maxRequestBytes: opt(posInt), images: opt(T.obj({ resize: opt(ImageResize), maxPerMessage: opt(posInt), maxPerRequest: opt(posInt) })) });
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
165
|
+
// compat-schema.js's ProviderCompatSchema: the merged properties of the OpenAI completions, OpenAI responses, Anthropic
|
|
166
|
+
// messages, Bedrock and Mistral schemas. A key two of them share takes pi's generic override, which for every shared
|
|
167
|
+
// key here is the same type as each api's own (a boolean, or the three-way affinity format), so the superset is exact.
|
|
168
|
+
const bool = opt(T.bool());
|
|
169
|
+
const ProviderCompat = T.obj({
|
|
170
|
+
// OpenAI completions
|
|
171
|
+
supportsStore: bool,
|
|
172
|
+
supportsDeveloperRole: bool,
|
|
173
|
+
supportsReasoningEffort: bool,
|
|
174
|
+
supportsUsageInStreaming: bool,
|
|
175
|
+
supportsFinishReason: bool,
|
|
176
|
+
maxTokensField: opt(T.union(T.lit("max_completion_tokens"), T.lit("max_tokens"))),
|
|
177
|
+
requiresToolResultName: bool,
|
|
178
|
+
requiresAssistantAfterToolResult: bool,
|
|
179
|
+
requiresThinkingAsText: bool,
|
|
180
|
+
requiresReasoningContentOnAssistantMessages: bool,
|
|
181
|
+
thinkingFormat: opt(T.union(...["openai", "openrouter", "deepseek", "together", "baseten", "zai", "qwen", "chat-template", "qwen-chat-template", "string-thinking", "ant-ling"].map((x) => T.lit(x)))),
|
|
182
|
+
chatTemplateKwargs: opt(T.rec(KwargValue)),
|
|
183
|
+
chatTemplateArgs: opt(T.rec(KwargValue)),
|
|
184
|
+
openRouterRouting: opt(OpenRouterRouting),
|
|
185
|
+
vercelGatewayRouting: opt(VercelGatewayRouting),
|
|
186
|
+
zaiToolStream: bool,
|
|
187
|
+
thinkingTokenBudgetField: opt(T.union(T.lit("thinking_token_budget"), T.lit("thinking_budget"), T.lit("thinking_budget_tokens"))),
|
|
188
|
+
supportsThinkingTokenBudget: bool,
|
|
189
|
+
supportsOpenAIGrammarTools: bool,
|
|
190
|
+
supportsMidConvoSystemMessages: bool,
|
|
191
|
+
supportsMidConvoToolAdditions: bool,
|
|
192
|
+
supportsStrictMode: bool,
|
|
193
|
+
cacheControlFormat: opt(T.lit("anthropic")),
|
|
194
|
+
sendSessionAffinityHeaders: bool,
|
|
195
|
+
sessionAffinityFormat: opt(affinity),
|
|
196
|
+
supportsLongCacheRetention: bool,
|
|
197
|
+
vllmPriority: opt(T.num()),
|
|
198
|
+
// OpenAI responses
|
|
199
|
+
supportsAdditionalTools: bool,
|
|
200
|
+
supportsToolSearch: bool,
|
|
201
|
+
supportsExplicitPromptCacheMode: bool,
|
|
202
|
+
supportsMaxOutputTokens: bool,
|
|
203
|
+
// Anthropic messages
|
|
204
|
+
supportsEagerToolInputStreaming: bool,
|
|
205
|
+
supportsCacheControlOnTools: bool,
|
|
206
|
+
supportsTemperature: bool,
|
|
207
|
+
forceAdaptiveThinking: bool,
|
|
208
|
+
allowEmptySignature: bool,
|
|
209
|
+
supportsStrictTools: bool,
|
|
210
|
+
supportsMidConvoEffort: bool,
|
|
211
|
+
supportsMidConvoToolChanges: bool,
|
|
202
212
|
allowedFallbackModels: opt(T.arr(T.obj({ provider: req(T.str({ minLength: 1 })), model: req(T.str({ minLength: 1 })), cost: req(ModelCost) }), { maxItems: 3 })),
|
|
203
213
|
});
|
|
204
|
-
const ProviderCompat = T.union(OpenAICompletionsCompat, OpenAIResponsesCompat, AnthropicMessagesCompat);
|
|
205
214
|
const nonEmpty = T.str({ minLength: 1 });
|
|
206
215
|
const inputKinds = T.arr(T.union(T.lit("text"), T.lit("image")));
|
|
207
216
|
const headers = T.rec(T.str());
|
|
217
|
+
// 1.2.0: a token count must be above zero, where any number passed before.
|
|
218
|
+
const tokenCount = T.num({ exclusiveMinimum: 0 });
|
|
208
219
|
const ModelDefinition = T.obj({
|
|
209
220
|
id: req(nonEmpty),
|
|
210
221
|
name: opt(nonEmpty),
|
|
@@ -216,8 +227,8 @@ const ModelDefinition = T.obj({
|
|
|
216
227
|
inputLimits: opt(ModelInputLimits),
|
|
217
228
|
cost: opt(ModelCost),
|
|
218
229
|
promptCache: opt(ModelPromptCache),
|
|
219
|
-
contextWindow: opt(
|
|
220
|
-
maxTokens: opt(
|
|
230
|
+
contextWindow: opt(tokenCount),
|
|
231
|
+
maxTokens: opt(tokenCount),
|
|
221
232
|
samplingParams: opt(SamplingParams),
|
|
222
233
|
samplingParamsByThinkingLevel: opt(SamplingParamsByThinkingLevel),
|
|
223
234
|
headers: opt(headers),
|
|
@@ -231,8 +242,8 @@ const ModelOverride = T.obj({
|
|
|
231
242
|
inputLimits: opt(ModelInputLimits),
|
|
232
243
|
cost: opt(T.obj({ input: opt(T.num()), output: opt(T.num()), cacheRead: opt(T.num()), cacheWrite: opt(T.num()), tiers: opt(T.arr(ModelCostTier)) })),
|
|
233
244
|
promptCache: opt(ModelPromptCache),
|
|
234
|
-
contextWindow: opt(
|
|
235
|
-
maxTokens: opt(
|
|
245
|
+
contextWindow: opt(tokenCount),
|
|
246
|
+
maxTokens: opt(tokenCount),
|
|
236
247
|
samplingParams: opt(SamplingParams),
|
|
237
248
|
samplingParamsByThinkingLevel: opt(SamplingParamsByThinkingLevel),
|
|
238
249
|
headers: opt(headers),
|
|
@@ -250,7 +261,8 @@ const ProviderConfig = T.obj({
|
|
|
250
261
|
models: opt(T.arr(ModelDefinition)),
|
|
251
262
|
modelOverrides: opt(T.rec(ModelOverride)),
|
|
252
263
|
});
|
|
253
|
-
|
|
264
|
+
// 1.2.0: a `$schema` key (the published https://pi.dev/schemas/models.schema.json) is declared, as a string.
|
|
265
|
+
const ModelsConfig = T.obj({ $schema: opt(T.str()), providers: req(T.rec(ProviderConfig)) });
|
|
254
266
|
|
|
255
267
|
/**
|
|
256
268
|
* Parse overlay `models.json` TEXT as pi does. `{ value }` when pi would load it, else `{ error }` with a fixed
|
package/src/outbox.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { readFlowGate as realReadFlowGate } from "./flow-gate.mjs";
|
|
4
4
|
import { chainedJobId } from "./job-id.mjs";
|
|
5
5
|
import { enqueueLocalJob } from "./queue.mjs";
|
|
6
|
+
import { withoutMcpExclusions } from "./triggers.mjs";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The outbox chain collector: the host-side reader of a completed LOCAL parent's `/outbox`, the
|
|
@@ -207,7 +208,9 @@ export function makeCollectChain({ queue, enqueue = enqueueLocalJob, readFlowGat
|
|
|
207
208
|
// parent would chain a child that can edit and run bash, a widening no operator wrote.
|
|
208
209
|
// Off `job.data`, never off `req` -- the request file can neither set nor drop it (explicit
|
|
209
210
|
// property reads only, above), so the agent cannot widen its child by omitting a key.
|
|
210
|
-
|
|
211
|
+
// Less the names of the parent's MCP tools: the child connects no server (`mcp` below), its runner knows
|
|
212
|
+
// none of them, and they would refuse it at start while removing nothing.
|
|
213
|
+
excludeTools: withoutMcpExclusions(job.data?.excludeTools),
|
|
211
214
|
// `secrets`/`secretsProfile` are deliberately ABSENT, and the two lines above are exactly why
|
|
212
215
|
// this comment exists: their reasoning reads as though it should apply here too, and it must
|
|
213
216
|
// not. An image and a skills directory are toolchain; a resolved credential is a capability.
|
|
@@ -219,6 +222,9 @@ export function makeCollectChain({ queue, enqueue = enqueueLocalJob, readFlowGat
|
|
|
219
222
|
// `portfolio` (issue #505) is ABSENT for the same reason, and more plainly: it is budget authority, granted
|
|
220
223
|
// to one cron trigger the operator reviewed. A child is an agent's request, so inheriting the flag would let
|
|
221
224
|
// a portfolio job queue a second plan writer of its own choosing.
|
|
225
|
+
// `mcp` (REQ-MCP-OPERATOR-SERVERS) is ABSENT for the secrets' reason: a server is reached with the
|
|
226
|
+
// credentials its trigger maps, a capability granted to the trigger the operator reviewed. A child
|
|
227
|
+
// without it connects no server, which is the narrowing direction.
|
|
222
228
|
chainDepth: childDepth,
|
|
223
229
|
parentJobId: job.id,
|
|
224
230
|
// chainedJobId deliberately does NOT take the image: the child's identity is (parent, flow, task).
|
package/src/parked.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REQ-AWAIT-INPUT, the reply half (issue #625): the forge marker on an issue whose last run is waiting for an answer.
|
|
3
|
+
*
|
|
4
|
+
* Imported by both services, like capabilities.mjs, because the receiver routes on it and the worker writes it: two
|
|
5
|
+
* spellings of one label would be two places for one to drift while every test stayed green.
|
|
6
|
+
*
|
|
7
|
+
* THE LABEL IS A HINT, THE SIDECAR IS THE TRUTH. The receiver cannot read the session store (on a fleet it may not
|
|
8
|
+
* even run beside one), so a label the forge already sends in every comment payload is what lets it route a reply
|
|
9
|
+
* that carries no trigger phrase. Anyone who may label an issue can set it, so it decides nothing on its own: the
|
|
10
|
+
* worker refuses such a reply before any spend unless the key's `parked` sidecar exists (`not-parked`). And it
|
|
11
|
+
* widens WHICH comments start a job, never WHO may: the comment trigger's author gate still runs first.
|
|
12
|
+
*/
|
|
13
|
+
export const PARKED_LABEL = "pi-awaiting-input";
|
|
14
|
+
|
|
15
|
+
/** Whether a subset's `[{ name }]` label list (or Azure's tag list, the same shape) carries the marker. */
|
|
16
|
+
export function hasParkedLabel(labels) {
|
|
17
|
+
return Array.isArray(labels) && labels.some((label) => label?.name === PARKED_LABEL);
|
|
18
|
+
}
|
package/src/prepare.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { rm } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { resolveBackendName } from "./backend-registry.mjs";
|
|
5
5
|
import { ensureJobsDir } from "./config.mjs";
|
|
6
|
+
import { JOB_MCP_FILE } from "./mcp-config.mjs";
|
|
6
7
|
import { resolveJobImage } from "./image-preflight.mjs";
|
|
7
8
|
import { retainJobDir } from "./sandbox-store.mjs";
|
|
8
9
|
import { prepareGithubWorkspace } from "./prepare-github.mjs";
|
|
@@ -84,7 +85,7 @@ export function makePrepareWorkspace({
|
|
|
84
85
|
removeDir = (dir) => rmSync(dir, { recursive: true, force: true }),
|
|
85
86
|
}) {
|
|
86
87
|
ensureDir(jobsDir);
|
|
87
|
-
return async function prepareWorkspace(job, token, { queueJobId, piVersion = null, jobUser = null, podmanStore = null, portfolio = false, size = null } = {}) {
|
|
88
|
+
return async function prepareWorkspace(job, token, { queueJobId, piVersion = null, jobUser = null, podmanStore = null, portfolio = false, size = null, mcp = null } = {}) {
|
|
88
89
|
ensureDir(jobsDir);
|
|
89
90
|
const jobDir = mkdtempSync(join(jobsDir, "job-"));
|
|
90
91
|
// Issue #524: a THROW out of anything below leaves `jobDir` to nobody. The processor tears down only what
|
|
@@ -94,7 +95,7 @@ export function makePrepareWorkspace({
|
|
|
94
95
|
// covers the thrown ones, in one place for every kind, rather than in each preparer that can throw.
|
|
95
96
|
// A retry makes a fresh directory, so nothing is lost by removing this one.
|
|
96
97
|
try {
|
|
97
|
-
return await prepareInto(job, token, jobDir, { queueJobId, piVersion, jobUser, podmanStore, portfolio, size });
|
|
98
|
+
return await prepareInto(job, token, jobDir, { queueJobId, piVersion, jobUser, podmanStore, portfolio, size, mcp });
|
|
98
99
|
} catch (error) {
|
|
99
100
|
// GUARDED: a removal that fails (a busy mount, a permission flipped mid-job) must never replace the error
|
|
100
101
|
// that is the job's actual outcome. The directory is then left, which is what happened before this catch.
|
|
@@ -105,7 +106,7 @@ export function makePrepareWorkspace({
|
|
|
105
106
|
}
|
|
106
107
|
};
|
|
107
108
|
|
|
108
|
-
async function prepareInto(job, token, jobDir, { queueJobId, piVersion, jobUser, podmanStore, portfolio, size }) {
|
|
109
|
+
async function prepareInto(job, token, jobDir, { queueJobId, piVersion, jobUser, podmanStore, portfolio, size, mcp }) {
|
|
109
110
|
// The trigger's injected skills (REQ-PER-TRIGGER-SKILLS, issue #60), COPIED here rather than
|
|
110
111
|
// mounted, and copied ONCE for every job kind because this is where local and forge converge.
|
|
111
112
|
//
|
|
@@ -126,6 +127,13 @@ export function makePrepareWorkspace({
|
|
|
126
127
|
// a host path by a later edit (the same shape packages.mjs's `dropped` record has).
|
|
127
128
|
log("trigger_skills_injected", { dirs: injected.dirs, files: injected.files, bytes: injected.bytes });
|
|
128
129
|
}
|
|
130
|
+
// The job's MCP servers (REQ-MCP-OPERATOR-SERVERS), as the processor's check passed them: written here, beside the
|
|
131
|
+
// injected skills, for their reason (no new mount, and fixed for the life of the job). References only, never a
|
|
132
|
+
// value (mcp-config.mjs `jobMcpDocument`), and read-only to the job like everything under /job.
|
|
133
|
+
if (mcp) {
|
|
134
|
+
writeFileSync(join(jobDir, JOB_MCP_FILE), `${JSON.stringify(mcp, null, 2)}\n`, { mode: 0o444 });
|
|
135
|
+
log("mcp_config_written", { servers: Object.keys(mcp.servers).length, refs: Object.values(mcp.refs).reduce((n, list) => n + list.length, 0) });
|
|
136
|
+
}
|
|
129
137
|
// What `cleanup` needs to retain this run's directory, stamped here because this is the only place
|
|
130
138
|
// that holds all of it at once. Applied to the RESULT rather than mutated in, so a preparer's
|
|
131
139
|
// `{ outcome: "policy" }` refusal -- which carries no jobDir -- is passed through untouched.
|