impel-cli 0.20.2 → 0.20.4
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/README.md +61 -68
- package/RELEASE_NOTES.md +13 -0
- package/package.json +2 -1
- package/src/agents.js +94 -13
- package/src/apps.js +26 -2
- package/src/cli.js +2 -1
- package/src/commands/launch.js +11 -2
- package/src/commands/mcp.js +30 -8
- package/src/commands/remote.js +430 -26
- package/src/directAnswer.js +44 -0
- package/src/nativeAgentTransport.js +95 -7
- package/src/remote/aws.js +38 -0
- package/src/remote/broker.js +296 -0
- package/src/remote/checkpoint.js +248 -0
- package/src/remote/contracts.js +89 -0
- package/src/remote/state.js +13 -1
- package/src/remote/telemetry.js +29 -0
- package/src/remote/transfer.js +14 -6
- package/src/remote/workspace.js +482 -0
- package/src/selfInvocation.js +6 -2
- package/src/verbatimRelay.js +64 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { VERBATIM_FINAL_TEXT_CONSTRAINTS } from "./verbatimRelay.js";
|
|
2
|
+
|
|
3
|
+
export function usesDirectAnswer(agent) {
|
|
4
|
+
// Write-capable agents stay on durable start/resume even if a catalog
|
|
5
|
+
// mistakenly sets directAnswer; Impel's answer_native_agent rejects writers.
|
|
6
|
+
return agent?.directAnswer === true && agent?.sideEffects !== "writes";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function extractAnswerFinalText(payload) {
|
|
10
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
11
|
+
for (const key of ["forUser", "answer"]) {
|
|
12
|
+
const text = payload[key];
|
|
13
|
+
if (typeof text === "string" && text.trim()) return text;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function claudeAnswerVerbatimCompletionGuidance() {
|
|
19
|
+
return (
|
|
20
|
+
`When it succeeds, return forUser if present, otherwise answer, exactly with ${VERBATIM_FINAL_TEXT_CONSTRAINTS}. ` +
|
|
21
|
+
"When it fails, return the tool error without inventing a replacement result."
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function claudeAnswerFaithfulCompletionGuidance() {
|
|
26
|
+
return (
|
|
27
|
+
"When it succeeds, return forUser if present, otherwise answer, faithfully as the answer. " +
|
|
28
|
+
"When it fails, return the tool error without inventing a replacement result."
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function adapterAnswerVerbatimCompletionGuidance() {
|
|
33
|
+
return (
|
|
34
|
+
`Return forUser if present, otherwise answer, verbatim with ${VERBATIM_FINAL_TEXT_CONSTRAINTS}. ` +
|
|
35
|
+
"Return tool failures without inventing a replacement result."
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function adapterAnswerFaithfulCompletionGuidance() {
|
|
40
|
+
return (
|
|
41
|
+
"Return forUser if present, otherwise answer, faithfully as the answer. " +
|
|
42
|
+
"Return tool failures without inventing a replacement result."
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -9,19 +9,27 @@ import {
|
|
|
9
9
|
redactSecretText,
|
|
10
10
|
} from "./config.js";
|
|
11
11
|
import {
|
|
12
|
+
NATIVE_AGENT_ANSWER_TOOL,
|
|
12
13
|
NATIVE_AGENT_LIST_TOOL,
|
|
13
14
|
NATIVE_AGENT_READ_TOOL,
|
|
14
15
|
NATIVE_AGENT_RECOVER_TOOL,
|
|
15
16
|
NATIVE_AGENT_RESUME_TOOL,
|
|
16
17
|
NATIVE_AGENT_RUN_TOOL,
|
|
17
18
|
NATIVE_AGENT_START_TOOL,
|
|
19
|
+
NATIVE_AGENT_UPSTREAM_ANSWER_TOOL,
|
|
18
20
|
nativeAgentPolicyFingerprint,
|
|
19
21
|
normalizeNativeAgentCatalog,
|
|
20
22
|
} from "./agents.js";
|
|
23
|
+
import { extractAnswerFinalText } from "./directAnswer.js";
|
|
21
24
|
import { normalizeTenantId } from "./tenants.js";
|
|
22
25
|
import { renameWithWindowsRetry } from "./windowsFs.js";
|
|
23
26
|
|
|
24
|
-
export {
|
|
27
|
+
export {
|
|
28
|
+
NATIVE_AGENT_ANSWER_TOOL,
|
|
29
|
+
NATIVE_AGENT_RECOVER_TOOL,
|
|
30
|
+
NATIVE_AGENT_RESUME_TOOL,
|
|
31
|
+
NATIVE_AGENT_RUN_TOOL,
|
|
32
|
+
};
|
|
25
33
|
export const NATIVE_AGENT_HANDLE_SCHEMA = "impel.native-agent-run.v1";
|
|
26
34
|
export const NATIVE_AGENT_RESULT_SCHEMA = "impel.native-agent-result.v1";
|
|
27
35
|
export const NATIVE_AGENT_RECOVERY_SCHEMA = "impel.native-agent-recovery.v1";
|
|
@@ -1543,6 +1551,28 @@ export class NativeAgentCompositeTransport {
|
|
|
1543
1551
|
normalizeRunArguments(args, agent) {
|
|
1544
1552
|
exactObject(args, ["task", "context", "contextKeys"], "run_native_agent arguments");
|
|
1545
1553
|
const task = boundedString(args.task, "task", 40_000);
|
|
1554
|
+
const context = this.normalizeContextArguments(args, agent);
|
|
1555
|
+
return {
|
|
1556
|
+
agentId: this.agentId,
|
|
1557
|
+
scopeParam: this.scopeParam,
|
|
1558
|
+
task,
|
|
1559
|
+
...context,
|
|
1560
|
+
...(agent.sideEffects === "writes" ? { confirmedSideEffects: true } : {}),
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
normalizeAnswerArguments(args, agent) {
|
|
1565
|
+
exactObject(args, ["question", "task", "context", "contextKeys"], "answer_native_agent arguments");
|
|
1566
|
+
const supplied = ["question", "task"].filter((key) => Object.hasOwn(args, key));
|
|
1567
|
+
if (supplied.length !== 1) {
|
|
1568
|
+
throw new Error("answer_native_agent requires exactly one of question or task");
|
|
1569
|
+
}
|
|
1570
|
+
const question = boundedString(args[supplied[0]], supplied[0], 40_000);
|
|
1571
|
+
const context = this.normalizeContextArguments(args, agent);
|
|
1572
|
+
return { agentId: this.agentId, scopeParam: this.scopeParam, question, ...context };
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
normalizeContextArguments(args, agent) {
|
|
1546
1576
|
const keys = contextKeys(args.contextKeys);
|
|
1547
1577
|
const missing = agent.requiredContext.filter((key) => !keys.includes(key));
|
|
1548
1578
|
if (missing.length) throw new Error(`missing required context: ${missing.join(", ")}`);
|
|
@@ -1551,12 +1581,37 @@ export class NativeAgentCompositeTransport {
|
|
|
1551
1581
|
throw new Error("nonblank context is required for this native agent");
|
|
1552
1582
|
}
|
|
1553
1583
|
return {
|
|
1554
|
-
agentId: this.agentId,
|
|
1555
|
-
scopeParam: this.scopeParam,
|
|
1556
|
-
task,
|
|
1557
1584
|
...(args.context !== undefined ? { context: args.context } : {}),
|
|
1558
1585
|
contextKeys: keys,
|
|
1559
|
-
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
async answer(args, { signal } = {}) {
|
|
1590
|
+
throwIfAborted(signal);
|
|
1591
|
+
const prepared = await this.prepareNewSession(signal);
|
|
1592
|
+
if (prepared.agent.directAnswer !== true) {
|
|
1593
|
+
throw new Error("native-agent binding is not configured for direct answers");
|
|
1594
|
+
}
|
|
1595
|
+
if (prepared.agent.sideEffects === "writes") {
|
|
1596
|
+
throw new Error(
|
|
1597
|
+
"write-capable native agents must use start/resume instead of answer_native_agent",
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
const payload = await prepared.session.call(
|
|
1601
|
+
NATIVE_AGENT_UPSTREAM_ANSWER_TOOL,
|
|
1602
|
+
this.normalizeAnswerArguments(args, prepared.agent),
|
|
1603
|
+
);
|
|
1604
|
+
const finalText = extractAnswerFinalText(payload);
|
|
1605
|
+
if (finalText === null) throw new Error("native-agent answer returned no forUser or answer");
|
|
1606
|
+
return {
|
|
1607
|
+
...(typeof payload.forUser === "string" && payload.forUser.trim()
|
|
1608
|
+
? { forUser: payload.forUser }
|
|
1609
|
+
: {}),
|
|
1610
|
+
...(typeof payload.answer === "string" && payload.answer.trim()
|
|
1611
|
+
? { answer: payload.answer }
|
|
1612
|
+
: {}),
|
|
1613
|
+
agentId: this.agentId,
|
|
1614
|
+
scopeParam: this.scopeParam,
|
|
1560
1615
|
};
|
|
1561
1616
|
}
|
|
1562
1617
|
|
|
@@ -1923,7 +1978,33 @@ const HANDLE_SCHEMA = {
|
|
|
1923
1978
|
},
|
|
1924
1979
|
};
|
|
1925
1980
|
|
|
1926
|
-
export function nativeAgentCompositeTools({
|
|
1981
|
+
export function nativeAgentCompositeTools({ mode = "durable" } = {}) {
|
|
1982
|
+
if (!["durable", "recovery", "answer"].includes(mode)) throw new Error("invalid native-agent MCP mode");
|
|
1983
|
+
if (mode === "answer") {
|
|
1984
|
+
return [{
|
|
1985
|
+
name: NATIVE_AGENT_ANSWER_TOOL,
|
|
1986
|
+
description: "Answer once through the direct-answer native agent fixed by this MCP server.",
|
|
1987
|
+
inputSchema: {
|
|
1988
|
+
type: "object",
|
|
1989
|
+
additionalProperties: false,
|
|
1990
|
+
anyOf: [
|
|
1991
|
+
{ required: ["question"], not: { required: ["task"] } },
|
|
1992
|
+
{ required: ["task"], not: { required: ["question"] } },
|
|
1993
|
+
],
|
|
1994
|
+
properties: {
|
|
1995
|
+
question: { type: "string", minLength: 1, maxLength: 40_000 },
|
|
1996
|
+
task: { type: "string", minLength: 1, maxLength: 40_000 },
|
|
1997
|
+
context: { type: "string", maxLength: 40_000 },
|
|
1998
|
+
contextKeys: {
|
|
1999
|
+
type: "array",
|
|
2000
|
+
maxItems: 30,
|
|
2001
|
+
uniqueItems: true,
|
|
2002
|
+
items: { type: "string", minLength: 1, maxLength: 160 },
|
|
2003
|
+
},
|
|
2004
|
+
},
|
|
2005
|
+
},
|
|
2006
|
+
}];
|
|
2007
|
+
}
|
|
1927
2008
|
const tools = [
|
|
1928
2009
|
{
|
|
1929
2010
|
name: NATIVE_AGENT_RUN_TOOL,
|
|
@@ -1964,10 +2045,17 @@ export function nativeAgentCompositeTools({ recoveryOnly = false } = {}) {
|
|
|
1964
2045
|
},
|
|
1965
2046
|
},
|
|
1966
2047
|
];
|
|
1967
|
-
return
|
|
2048
|
+
return mode === "recovery" ? tools.filter(({ name }) => name !== NATIVE_AGENT_RUN_TOOL) : tools;
|
|
1968
2049
|
}
|
|
1969
2050
|
|
|
1970
2051
|
export function nativeAgentToolCallResult(value) {
|
|
2052
|
+
const answerText = extractAnswerFinalText(value);
|
|
2053
|
+
if (answerText !== null) {
|
|
2054
|
+
return {
|
|
2055
|
+
content: [{ type: "text", text: answerText }],
|
|
2056
|
+
structuredContent: value,
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
1971
2059
|
if (value?.schema === NATIVE_AGENT_RESULT_SCHEMA && value.status === "succeeded") {
|
|
1972
2060
|
return {
|
|
1973
2061
|
content: [{ type: "text", text: value.finalText }],
|
package/src/remote/aws.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { runCapture, runInteractive, sleep } from "./process.js";
|
|
2
2
|
|
|
3
|
+
// LEGACY ONLY: direct end-user AWS/ECS/SSM access exists solely for the
|
|
4
|
+
// explicitly flagged portable-legacy rollback path. New modes use the broker.
|
|
5
|
+
export const LEGACY_DIRECT_AWS_ONLY = true;
|
|
6
|
+
|
|
3
7
|
function awsPrefix({ region, profile }) {
|
|
4
8
|
return [
|
|
5
9
|
...(region ? ["--region", region] : []),
|
|
@@ -133,6 +137,40 @@ export function startSshProxy(context, state, port) {
|
|
|
133
137
|
], { allowFailure: true });
|
|
134
138
|
}
|
|
135
139
|
|
|
140
|
+
export function startBrokerSshProxy(connection) {
|
|
141
|
+
if (connection?.transport !== "aws-ssm-start-session") {
|
|
142
|
+
throw new Error(`unsupported broker connection transport ${JSON.stringify(connection?.transport || null)}`);
|
|
143
|
+
}
|
|
144
|
+
const payload = connection.payload;
|
|
145
|
+
if (
|
|
146
|
+
!payload || typeof payload !== "object"
|
|
147
|
+
|| typeof payload.sessionId !== "string" || !payload.sessionId
|
|
148
|
+
|| typeof payload.streamUrl !== "string" || !payload.streamUrl.startsWith("wss://")
|
|
149
|
+
|| typeof payload.tokenValue !== "string" || !payload.tokenValue
|
|
150
|
+
|| typeof payload.target !== "string" || !payload.target
|
|
151
|
+
|| typeof payload.documentName !== "string" || !payload.documentName
|
|
152
|
+
|| !payload.parameters || typeof payload.parameters !== "object"
|
|
153
|
+
|| typeof payload.region !== "string" || !payload.region
|
|
154
|
+
) throw new Error("broker returned an invalid AWS SSM connection descriptor");
|
|
155
|
+
// The plugin receives only this single-use broker lease. `allowFailure`
|
|
156
|
+
// prevents its token-bearing argv from ever being rendered in an error.
|
|
157
|
+
return runInteractive(process.env.IMPEL_REMOTE_SESSION_PLUGIN_BIN || "session-manager-plugin", [
|
|
158
|
+
JSON.stringify({
|
|
159
|
+
SessionId: payload.sessionId,
|
|
160
|
+
StreamUrl: payload.streamUrl,
|
|
161
|
+
TokenValue: payload.tokenValue,
|
|
162
|
+
}),
|
|
163
|
+
payload.region,
|
|
164
|
+
"StartSession",
|
|
165
|
+
"",
|
|
166
|
+
JSON.stringify({
|
|
167
|
+
Target: payload.target,
|
|
168
|
+
DocumentName: payload.documentName,
|
|
169
|
+
Parameters: payload.parameters,
|
|
170
|
+
}),
|
|
171
|
+
], { allowFailure: true });
|
|
172
|
+
}
|
|
173
|
+
|
|
136
174
|
export function awsContext(stateOrOptions) {
|
|
137
175
|
return {
|
|
138
176
|
region: stateOrOptions?.region || stateOrOptions?.aws?.region || "eu-west-2",
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import https from "node:https";
|
|
5
|
+
|
|
6
|
+
import { normalizeGatewayUrl, redactSecretText } from "../config.js";
|
|
7
|
+
import { fetchHttp1 } from "../http1.js";
|
|
8
|
+
import { normalizeCapabilityResponse } from "./contracts.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
11
|
+
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
12
|
+
const RUN_ID_RE = /^run_[A-Za-z0-9_-]{8,128}$/u;
|
|
13
|
+
const SESSIONS_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
14
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]", "localhost"]);
|
|
15
|
+
|
|
16
|
+
function secureHttpUrl(url) {
|
|
17
|
+
return url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isSessionsUUID(value) {
|
|
21
|
+
return SESSIONS_UUID_RE.test(String(value || ""));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function uploadTimeoutMs(length, baseTimeout) {
|
|
25
|
+
const bytes = Number(length);
|
|
26
|
+
const transferBudget = Number.isFinite(bytes) && bytes > 0 ? Math.ceil(bytes / (256 * 1024)) * 1000 : 0;
|
|
27
|
+
return Math.min(6 * 60 * 60 * 1000, Math.max(baseTimeout, 120_000, transferBudget + 60_000));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function newMutationKey(label = "mutation") {
|
|
31
|
+
const safeLabel = String(label).toLowerCase().replace(/[^a-z0-9_-]+/gu, "-").slice(0, 32) || "mutation";
|
|
32
|
+
return `impel-remote-${safeLabel}-${crypto.randomBytes(18).toString("hex")}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function responseError(status, payload) {
|
|
36
|
+
const code = typeof payload?.error?.code === "string" ? payload.error.code : `HTTP_${status}`;
|
|
37
|
+
const message = typeof payload?.error?.message === "string"
|
|
38
|
+
? payload.error.message
|
|
39
|
+
: `broker returned HTTP ${status}`;
|
|
40
|
+
const error = new Error(`${code}: ${redactSecretText(message)}`);
|
|
41
|
+
error.code = code;
|
|
42
|
+
error.status = status;
|
|
43
|
+
return error;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function parsedResponse(response) {
|
|
47
|
+
const text = await response.text();
|
|
48
|
+
let payload = null;
|
|
49
|
+
try { payload = text ? JSON.parse(text) : null; } catch { /* Report the HTTP status below. */ }
|
|
50
|
+
if (!response.ok) throw responseError(response.status, payload);
|
|
51
|
+
return payload;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sendFile(urlValue, { method = "PUT", headers = {}, filePath, signal }) {
|
|
55
|
+
const url = new URL(urlValue);
|
|
56
|
+
if (!secureHttpUrl(url) || url.username || url.password || url.hash) {
|
|
57
|
+
throw new Error("workspace upload returned an insecure or malformed URL");
|
|
58
|
+
}
|
|
59
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
60
|
+
const length = fs.statSync(filePath).size;
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const request = transport.request(url, {
|
|
63
|
+
method,
|
|
64
|
+
headers: { ...headers, "content-length": String(length) },
|
|
65
|
+
signal,
|
|
66
|
+
...(url.protocol === "https:" ? { ALPNProtocols: ["http/1.1"] } : {}),
|
|
67
|
+
}, (response) => {
|
|
68
|
+
const chunks = [];
|
|
69
|
+
let size = 0;
|
|
70
|
+
response.on("data", (chunk) => {
|
|
71
|
+
size += chunk.length;
|
|
72
|
+
if (size > MAX_RESPONSE_BYTES) request.destroy(new Error("upload response exceeded 2 MiB"));
|
|
73
|
+
else chunks.push(chunk);
|
|
74
|
+
});
|
|
75
|
+
response.on("error", reject);
|
|
76
|
+
response.on("end", () => {
|
|
77
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
78
|
+
resolve({
|
|
79
|
+
ok: (response.statusCode || 0) >= 200 && (response.statusCode || 0) < 300,
|
|
80
|
+
status: response.statusCode || 0,
|
|
81
|
+
async text() { return body; },
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
request.on("error", reject);
|
|
86
|
+
fs.createReadStream(filePath).on("error", reject).pipe(request);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeRun(value) {
|
|
91
|
+
const run = value?.run || value;
|
|
92
|
+
if (!run || typeof run !== "object" || !RUN_ID_RE.test(String(run.runId || ""))) {
|
|
93
|
+
throw new Error("broker returned an invalid remote run");
|
|
94
|
+
}
|
|
95
|
+
const state = String(run.state || run.status || "");
|
|
96
|
+
if (!state) throw new Error("broker returned a remote run without state");
|
|
97
|
+
return { ...run, runId: String(run.runId), state };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class RemoteBrokerClient {
|
|
101
|
+
constructor({ baseUrl, pat, fetchImpl = fetchHttp1, uploadImpl = sendFile, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
102
|
+
if (!pat) throw new Error("a PAT is required for the remote broker");
|
|
103
|
+
this.baseUrl = normalizeGatewayUrl(baseUrl);
|
|
104
|
+
const parsedBase = new URL(this.baseUrl);
|
|
105
|
+
if (
|
|
106
|
+
!secureHttpUrl(parsedBase)
|
|
107
|
+
|| parsedBase.username || parsedBase.password || parsedBase.search || parsedBase.hash
|
|
108
|
+
|| parsedBase.pathname !== "/"
|
|
109
|
+
) {
|
|
110
|
+
throw new Error("remote broker URL must be an HTTPS origin (or loopback HTTP for development) without a path, userinfo, query, or fragment");
|
|
111
|
+
}
|
|
112
|
+
this.pat = pat;
|
|
113
|
+
this.fetchImpl = fetchImpl;
|
|
114
|
+
this.uploadImpl = uploadImpl;
|
|
115
|
+
this.timeoutMs = timeoutMs;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async request(path, { method = "GET", body, headers = {}, idempotencyKey, retry = true } = {}) {
|
|
119
|
+
const request = async () => {
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
122
|
+
try {
|
|
123
|
+
const response = await this.fetchImpl(new URL(path, this.baseUrl), {
|
|
124
|
+
method,
|
|
125
|
+
headers: {
|
|
126
|
+
accept: "application/json",
|
|
127
|
+
authorization: `Bearer ${this.pat}`,
|
|
128
|
+
...(body === undefined || Buffer.isBuffer(body) ? {} : { "content-type": "application/json" }),
|
|
129
|
+
...(Buffer.isBuffer(body) ? { "content-type": "application/octet-stream" } : {}),
|
|
130
|
+
...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
|
|
131
|
+
...headers,
|
|
132
|
+
},
|
|
133
|
+
body: body === undefined ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body),
|
|
134
|
+
signal: controller.signal,
|
|
135
|
+
});
|
|
136
|
+
return parsedResponse(response);
|
|
137
|
+
} finally {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
return await request();
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const retryable = retry && idempotencyKey && (error?.name === "AbortError" || !error?.status || error.status >= 500);
|
|
145
|
+
if (!retryable) throw error;
|
|
146
|
+
return request();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async capability(expected) {
|
|
151
|
+
const url = new URL("/v1/remote-capabilities", this.baseUrl);
|
|
152
|
+
url.searchParams.set("provider", expected.provider);
|
|
153
|
+
url.searchParams.set("sourceSurface", expected.sourceSurface);
|
|
154
|
+
url.searchParams.set("requestedMode", expected.mode);
|
|
155
|
+
const payload = await this.request(`${url.pathname}${url.search}`);
|
|
156
|
+
return normalizeCapabilityResponse(payload, expected);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async createRun(body, idempotencyKey = newMutationKey("create")) {
|
|
160
|
+
if (!isSessionsUUID(body?.sessionId)) {
|
|
161
|
+
throw new Error("remote broker create requires a lowercase Sessions UUID");
|
|
162
|
+
}
|
|
163
|
+
return normalizeRun(await this.request("/v1/remote-runs", { method: "POST", body, idempotencyKey }));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async getRun(runId) {
|
|
167
|
+
return normalizeRun(await this.request(`/v1/remote-runs/${encodeURIComponent(runId)}`));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async cancelRun(runId, body = {}, idempotencyKey = newMutationKey("cancel")) {
|
|
171
|
+
return normalizeRun(await this.request(`/v1/remote-runs/${encodeURIComponent(runId)}/cancel`, {
|
|
172
|
+
method: "POST", body, idempotencyKey,
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async stopRun(runId, body = {}, idempotencyKey = newMutationKey("stop")) {
|
|
177
|
+
return normalizeRun(await this.request(`/v1/remote-runs/${encodeURIComponent(runId)}/stop`, {
|
|
178
|
+
method: "POST", body, idempotencyKey,
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async handbackRun(runId, idempotencyKey = newMutationKey("handback")) {
|
|
183
|
+
return normalizeRun(await this.request(`/v1/remote-runs/${encodeURIComponent(runId)}/handback`, {
|
|
184
|
+
method: "POST", body: {}, idempotencyKey,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async createConnection(runId, idempotencyKey = newMutationKey("connection")) {
|
|
189
|
+
const payload = await this.request(`/v1/remote-runs/${encodeURIComponent(runId)}/connections`, {
|
|
190
|
+
method: "POST", body: {}, idempotencyKey,
|
|
191
|
+
});
|
|
192
|
+
if (
|
|
193
|
+
!payload || typeof payload !== "object"
|
|
194
|
+
|| typeof payload.leaseId !== "string"
|
|
195
|
+
|| typeof payload.transport !== "string"
|
|
196
|
+
|| typeof payload.expiresAt !== "string"
|
|
197
|
+
|| !payload.payload || typeof payload.payload !== "object"
|
|
198
|
+
) throw new Error("broker returned an invalid connection lease");
|
|
199
|
+
return payload;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async createViewerLink(runId, idempotencyKey = newMutationKey("viewer-link")) {
|
|
203
|
+
const payload = await this.request(`/v1/remote-runs/${encodeURIComponent(runId)}/viewer-links`, {
|
|
204
|
+
method: "POST", body: {}, idempotencyKey, retry: false,
|
|
205
|
+
});
|
|
206
|
+
if (
|
|
207
|
+
!payload || typeof payload !== "object"
|
|
208
|
+
|| typeof payload.fragment !== "string"
|
|
209
|
+
|| !/^exchange=run_[A-Za-z0-9_-]{8,128}\.[A-Za-z0-9_-]{24,256}$/u.test(payload.fragment)
|
|
210
|
+
|| typeof payload.expiresAt !== "string"
|
|
211
|
+
|| !Number.isFinite(Date.parse(payload.expiresAt))
|
|
212
|
+
) throw new Error("broker returned an invalid viewer link");
|
|
213
|
+
const url = new URL(`/runs/${encodeURIComponent(runId)}`, this.baseUrl);
|
|
214
|
+
url.hash = payload.fragment;
|
|
215
|
+
return { url: url.toString(), expiresAt: payload.expiresAt };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async closeConnection(runId, leaseId, idempotencyKey = newMutationKey("connection-close")) {
|
|
219
|
+
return normalizeRun(await this.request(`/v1/remote-runs/${encodeURIComponent(runId)}/connections/${encodeURIComponent(leaseId)}/close`, {
|
|
220
|
+
method: "POST", body: {}, idempotencyKey,
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async uploadWorkspace(runId, artifact, idempotencyKey = newMutationKey("workspace-upload")) {
|
|
225
|
+
const capability = await this.request(`/v1/remote-runs/${encodeURIComponent(runId)}/workspace-upload`, {
|
|
226
|
+
method: "POST",
|
|
227
|
+
idempotencyKey,
|
|
228
|
+
body: {
|
|
229
|
+
length: artifact.length,
|
|
230
|
+
sha256: artifact.sha256,
|
|
231
|
+
manifestHash: artifact.manifestHash,
|
|
232
|
+
contentType: "application/vnd.impel.workspace.v1+tar",
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
if (
|
|
236
|
+
!capability || !new Set(["PUT"]).has(capability.method)
|
|
237
|
+
|| typeof capability.url !== "string"
|
|
238
|
+
|| typeof capability.objectKey !== "string"
|
|
239
|
+
|| !capability.headers || typeof capability.headers !== "object"
|
|
240
|
+
) throw new Error("broker returned an invalid workspace upload capability");
|
|
241
|
+
const controller = new AbortController();
|
|
242
|
+
const timer = setTimeout(() => controller.abort(), uploadTimeoutMs(artifact.length, this.timeoutMs));
|
|
243
|
+
try {
|
|
244
|
+
const response = await this.uploadImpl(capability.url, {
|
|
245
|
+
method: capability.method,
|
|
246
|
+
headers: capability.headers,
|
|
247
|
+
filePath: artifact.path,
|
|
248
|
+
signal: controller.signal,
|
|
249
|
+
});
|
|
250
|
+
if (!response.ok) throw responseError(response.status, null);
|
|
251
|
+
} finally {
|
|
252
|
+
clearTimeout(timer);
|
|
253
|
+
}
|
|
254
|
+
return normalizeRun(await this.request(`/v1/remote-runs/${encodeURIComponent(runId)}/workspace-complete`, {
|
|
255
|
+
method: "POST",
|
|
256
|
+
idempotencyKey: newMutationKey("workspace-complete"),
|
|
257
|
+
body: {
|
|
258
|
+
objectKey: capability.objectKey,
|
|
259
|
+
length: artifact.length,
|
|
260
|
+
sha256: artifact.sha256,
|
|
261
|
+
manifestHash: artifact.manifestHash,
|
|
262
|
+
},
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async uploadProviderCheckpoint(runId, checkpoint, idempotencyKey = newMutationKey("checkpoint")) {
|
|
267
|
+
const url = new URL(`/v1/remote-runs/${encodeURIComponent(runId)}/provider-checkpoints/input`, this.baseUrl);
|
|
268
|
+
const upload = () => {
|
|
269
|
+
const controller = new AbortController();
|
|
270
|
+
const timer = setTimeout(() => controller.abort(), uploadTimeoutMs(checkpoint.length, this.timeoutMs));
|
|
271
|
+
return this.uploadImpl(url, {
|
|
272
|
+
method: "POST",
|
|
273
|
+
filePath: checkpoint.path,
|
|
274
|
+
signal: controller.signal,
|
|
275
|
+
headers: {
|
|
276
|
+
accept: "application/json",
|
|
277
|
+
authorization: `Bearer ${this.pat}`,
|
|
278
|
+
"content-length": String(checkpoint.length),
|
|
279
|
+
"content-type": "application/vnd.impel.provider-checkpoint.v1+tar",
|
|
280
|
+
"idempotency-key": idempotencyKey,
|
|
281
|
+
"x-impel-content-sha256": checkpoint.sha256,
|
|
282
|
+
"x-impel-manifest-sha256": checkpoint.manifestHash,
|
|
283
|
+
},
|
|
284
|
+
}).finally(() => clearTimeout(timer));
|
|
285
|
+
};
|
|
286
|
+
let response;
|
|
287
|
+
try { response = await upload(); }
|
|
288
|
+
catch { response = await upload(); }
|
|
289
|
+
if (!response.ok && response.status >= 500) response = await upload();
|
|
290
|
+
const text = await response.text();
|
|
291
|
+
let payload = null;
|
|
292
|
+
try { payload = text ? JSON.parse(text) : null; } catch { /* handled below */ }
|
|
293
|
+
if (!response.ok) throw responseError(response.status, payload);
|
|
294
|
+
return normalizeRun(payload);
|
|
295
|
+
}
|
|
296
|
+
}
|