rainskills 0.1.41 → 0.1.43
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 +109 -229
- package/SKILL.md +3 -3
- package/bin/delivery-probe.js +306 -0
- package/bin/handoff-context.js +151 -0
- package/bin/protected-poll.js +116 -0
- package/bin/rainskills-tools.js +132 -8
- package/bin/read-snapshot.js +82 -0
- package/contracts/ai-engine-skills-prerequisites-v1.json +175 -0
- package/contracts/handoff-context.schema.yaml +62 -0
- package/contracts/runtime/cli-base.md +43 -0
- package/contracts/runtime/embedded-base.md +20 -0
- package/contracts/runtime/overlay.schema.json +24 -0
- package/contracts/runtime/routing-modes.yaml +30 -0
- package/contracts/runtime/skills/rainbond-ai-assistant.yaml +13 -0
- package/contracts/runtime/skills/rainbond-app-assistant.yaml +12 -0
- package/contracts/runtime/skills/rainbond-app-version-assistant.yaml +11 -0
- package/contracts/runtime/skills/rainbond-delivery-verifier.yaml +12 -0
- package/contracts/runtime/skills/rainbond-env-sync.yaml +12 -0
- package/contracts/runtime/skills/rainbond-fullstack-bootstrap.yaml +12 -0
- package/contracts/runtime/skills/rainbond-fullstack-troubleshooter.yaml +12 -0
- package/contracts/runtime/skills/rainbond-opensource-app-deploy.yaml +12 -0
- package/contracts/runtime/skills/rainbond-platform-plugin-manager.yaml +12 -0
- package/contracts/runtime/skills/rainbond-platform-query.yaml +12 -0
- package/contracts/runtime/skills/rainbond-project-init.yaml +12 -0
- package/contracts/runtime/skills/rainbond-template-installer.yaml +12 -0
- package/contracts/shared/user-result.md +14 -0
- package/dsh/cordis.patch.yml +6 -0
- package/dsh/index.mjs +73 -0
- package/install.sh +99 -36
- package/marketplace/rainskills/.claude-plugin/plugin.json +1 -1
- package/marketplace/rainskills/.codex-plugin/plugin.json +1 -1
- package/marketplace/rainskills/skills/rainskills/SKILL.md +4 -4
- package/package.json +48 -9
- package/rainbond-ai-assistant/SKILL.md +45 -0
- package/rainbond-ai-assistant/agents/openai.yaml +5 -0
- package/rainbond-ai-assistant/evals/scenarios.yaml +196 -0
- package/rainbond-ai-assistant/references/advanced-vllm-arguments.md +10 -0
- package/rainbond-ai-assistant/references/context-and-permissions.md +9 -0
- package/rainbond-ai-assistant/references/cpu-deployment.md +22 -0
- package/rainbond-ai-assistant/references/deletion-policy.md +8 -0
- package/rainbond-ai-assistant/references/deployment-diagnostics.md +18 -0
- package/rainbond-ai-assistant/references/error-recovery.md +17 -0
- package/rainbond-ai-assistant/references/generated/runtime-gate.md +189 -0
- package/rainbond-ai-assistant/references/generated/runtime-routing.md +15 -0
- package/rainbond-ai-assistant/references/generated/user-result.md +16 -0
- package/rainbond-ai-assistant/references/gpu-provider-and-capacity.md +44 -0
- package/rainbond-ai-assistant/references/instance-deployment.md +32 -0
- package/rainbond-ai-assistant/references/instance-lifecycle.md +10 -0
- package/rainbond-ai-assistant/references/model-discovery.md +16 -0
- package/rainbond-ai-assistant/references/model-download.md +31 -0
- package/rainbond-ai-assistant/references/model-selection.md +24 -0
- package/rainbond-ai-assistant/references/output-contract.md +21 -0
- package/rainbond-ai-assistant/references/parameter-decision-guide.md +122 -0
- package/rainbond-ai-assistant/references/throughput-tuning.md +12 -0
- package/rainbond-ai-assistant/schemas/ai-engine-result.schema.yaml +85 -0
- package/rainbond-ai-assistant/scripts/validate_ai_engine_output.py +42 -0
- package/rainbond-ai-assistant/scripts/validate_progressive_loading.py +28 -0
- package/rainbond-app-assistant/SKILL.md +9 -5
- package/rainbond-app-assistant/evals/04-delivered-stop-without-promotion.expected.yaml +0 -5
- package/rainbond-app-assistant/evals/04-delivered-stop-without-promotion.response.md +4 -25
- package/rainbond-app-assistant/evals/11-delivered-with-proxy-and-local-binding.expected.yaml +0 -9
- package/rainbond-app-assistant/evals/11-delivered-with-proxy-and-local-binding.response.md +4 -28
- package/rainbond-app-assistant/evals/16-source-delivered-manual-validation.expected.yaml +0 -6
- package/rainbond-app-assistant/evals/16-source-delivered-manual-validation.response.md +4 -23
- package/rainbond-app-assistant/evals/17-local-package-existing-app-needs-init.expected.yaml +26 -0
- package/rainbond-app-assistant/evals/17-local-package-existing-app-needs-init.response.md +46 -0
- package/rainbond-app-assistant/references/generated/runtime-gate.md +227 -0
- package/rainbond-app-assistant/references/generated/runtime-routing.md +29 -0
- package/rainbond-app-assistant/references/generated/user-result.md +16 -0
- package/rainbond-app-assistant/references/local-project-analysis.md +46 -0
- package/rainbond-app-assistant/references/output-contract.md +23 -47
- package/rainbond-app-assistant/references/product-object-model.md +9 -29
- package/rainbond-app-assistant/references/workflow-rules.md +33 -21
- package/rainbond-app-assistant/schemas/generated/handoff-context.schema.yaml +64 -0
- package/rainbond-app-assistant/scripts/validate_app_assistant_output.py +1 -0
- package/rainbond-app-assistant/scripts/validate_cross_skill_routing.py +50 -58
- package/rainbond-app-assistant/scripts/validate_progressive_loading.py +39 -9
- package/rainbond-app-version-assistant/SKILL.md +43 -560
- package/rainbond-app-version-assistant/references/generated/runtime-gate.md +174 -0
- package/rainbond-app-version-assistant/references/generated/runtime-routing.md +15 -0
- package/rainbond-app-version-assistant/references/generated/user-result.md +16 -0
- package/rainbond-app-version-assistant/references/output-contract.md +156 -0
- package/rainbond-app-version-assistant/references/version-operations.md +50 -0
- package/rainbond-app-version-assistant/references/version-workflow.md +168 -0
- package/rainbond-delivery-verifier/SKILL.md +45 -522
- package/rainbond-delivery-verifier/evals/01-delivered-verified.expected.yaml +24 -0
- package/rainbond-delivery-verifier/evals/01-delivered-verified.response.md +21 -1
- package/rainbond-delivery-verifier/evals/02-delivered-manual-validation.response.md +1 -0
- package/rainbond-delivery-verifier/evals/03-partially-delivered-building.response.md +1 -0
- package/rainbond-delivery-verifier/evals/04-blocked-capacity.response.md +1 -0
- package/rainbond-delivery-verifier/evals/05-blocked-no-usable-url.response.md +1 -0
- package/rainbond-delivery-verifier/evals/06-blocked-runtime-unhealthy.response.md +19 -0
- package/rainbond-delivery-verifier/references/delivery-workflow.md +89 -0
- package/rainbond-delivery-verifier/references/generated/runtime-gate.md +242 -0
- package/rainbond-delivery-verifier/references/generated/runtime-routing.md +15 -0
- package/rainbond-delivery-verifier/references/generated/user-result.md +16 -0
- package/rainbond-delivery-verifier/references/output-contract.md +154 -0
- package/rainbond-delivery-verifier/references/verification-rules.md +103 -0
- package/rainbond-delivery-verifier/schemas/delivery-probe-input.schema.yaml +20 -0
- package/rainbond-delivery-verifier/schemas/delivery-probe-policy.schema.yaml +40 -0
- package/rainbond-delivery-verifier/schemas/delivery-probe-result.schema.yaml +82 -0
- package/rainbond-delivery-verifier/schemas/delivery-verification-result.schema.yaml +65 -0
- package/rainbond-delivery-verifier/schemas/generated/handoff-context.schema.yaml +64 -0
- package/rainbond-delivery-verifier/scripts/validate_delivery_verifier_output.py +27 -0
- package/rainbond-env-sync/SKILL.md +42 -617
- package/rainbond-env-sync/evals/db-name-source-cases.yaml +25 -0
- package/rainbond-env-sync/references/generated/runtime-gate.md +175 -0
- package/rainbond-env-sync/references/generated/runtime-routing.md +15 -0
- package/rainbond-env-sync/references/generated/user-result.md +16 -0
- package/rainbond-env-sync/references/output-contract.md +212 -0
- package/rainbond-env-sync/references/sync-policy.md +148 -0
- package/rainbond-env-sync/references/sync-workflow.md +94 -0
- package/rainbond-fullstack-bootstrap/SKILL.md +55 -433
- package/rainbond-fullstack-bootstrap/evals/07-provider-connection-contract.expected.yaml +9 -0
- package/rainbond-fullstack-bootstrap/evals/07-provider-connection-contract.response.md +1 -1
- package/rainbond-fullstack-bootstrap/evals/18-existing-package-update.expected.yaml +28 -0
- package/rainbond-fullstack-bootstrap/evals/18-existing-package-update.prompt.md +1 -0
- package/rainbond-fullstack-bootstrap/evals/18-existing-package-update.response.md +39 -0
- package/rainbond-fullstack-bootstrap/modules/30-creation-rules.md +4 -232
- package/rainbond-fullstack-bootstrap/modules/35-runtime-topology-rules.md +194 -0
- package/rainbond-fullstack-bootstrap/modules/40-source-rules.md +94 -0
- package/rainbond-fullstack-bootstrap/modules/42-source-topology.md +70 -0
- package/rainbond-fullstack-bootstrap/modules/44-source-build-rules.md +55 -0
- package/rainbond-fullstack-bootstrap/modules/45-package-rules.md +83 -0
- package/rainbond-fullstack-bootstrap/modules/50-workflow.md +135 -0
- package/rainbond-fullstack-bootstrap/modules/55-convergence-rules.md +93 -0
- package/rainbond-fullstack-bootstrap/modules/60-verification-and-handoffs.md +1 -1
- package/rainbond-fullstack-bootstrap/modules/70-output-contract.md +2 -0
- package/rainbond-fullstack-bootstrap/references/generated/runtime-gate.md +262 -0
- package/rainbond-fullstack-bootstrap/references/generated/runtime-routing.md +24 -0
- package/rainbond-fullstack-bootstrap/references/generated/user-result.md +16 -0
- package/rainbond-fullstack-bootstrap/references/manifest-v1-reference.md +1 -1
- package/rainbond-fullstack-bootstrap/references/sql-init-recipe.md +1 -1
- package/rainbond-fullstack-bootstrap/schemas/generated/handoff-context.schema.yaml +64 -0
- package/rainbond-fullstack-bootstrap/scripts/validate_bootstrap_output.py +10 -0
- package/rainbond-fullstack-troubleshooter/SKILL.md +47 -1030
- package/rainbond-fullstack-troubleshooter/evals/10-config-file-configmap-missing.expected.yaml +6 -0
- package/rainbond-fullstack-troubleshooter/evals/10-config-file-configmap-missing.response.md +8 -7
- package/rainbond-fullstack-troubleshooter/references/diagnosis-context.md +211 -0
- package/rainbond-fullstack-troubleshooter/references/diagnosis-workflow.md +118 -0
- package/rainbond-fullstack-troubleshooter/references/generated/runtime-gate.md +227 -0
- package/rainbond-fullstack-troubleshooter/references/generated/runtime-routing.md +15 -0
- package/rainbond-fullstack-troubleshooter/references/generated/troubleshoot-contract.md +127 -0
- package/rainbond-fullstack-troubleshooter/references/generated/user-result.md +16 -0
- package/rainbond-fullstack-troubleshooter/references/output-contract.md +3 -67
- package/rainbond-fullstack-troubleshooter/references/root-cause-rules.md +3 -2
- package/rainbond-fullstack-troubleshooter/schemas/generated/handoff-context.schema.yaml +64 -0
- package/rainbond-fullstack-troubleshooter/scripts/generate_troubleshoot_contract.mjs +100 -0
- package/rainbond-fullstack-troubleshooter/scripts/validate_troubleshoot_output.py +0 -17
- package/rainbond-opensource-app-deploy/SKILL.md +8 -4
- package/rainbond-opensource-app-deploy/references/deployment-workflow.md +2 -2
- package/rainbond-opensource-app-deploy/references/generated/runtime-gate.md +188 -0
- package/rainbond-opensource-app-deploy/references/generated/runtime-routing.md +24 -0
- package/rainbond-opensource-app-deploy/references/generated/user-result.md +16 -0
- package/rainbond-platform-installer/SKILL.md +2 -2
- package/rainbond-platform-installer/scripts/installed-version.js +1 -1
- package/rainbond-platform-installer/scripts/user-message.js +7 -9
- package/rainbond-platform-plugin-manager/SKILL.md +38 -0
- package/rainbond-platform-plugin-manager/agents/openai.yaml +5 -0
- package/rainbond-platform-plugin-manager/evals/scenarios.yaml +28 -0
- package/rainbond-platform-plugin-manager/references/generated/runtime-gate.md +188 -0
- package/rainbond-platform-plugin-manager/references/generated/runtime-routing.md +15 -0
- package/rainbond-platform-plugin-manager/references/generated/user-result.md +16 -0
- package/rainbond-platform-plugin-manager/references/output-contract.md +14 -0
- package/rainbond-platform-plugin-manager/references/plugin-discovery.md +12 -0
- package/rainbond-platform-plugin-manager/references/plugin-lifecycle.md +25 -0
- package/rainbond-platform-plugin-manager/references/recovery.md +13 -0
- package/rainbond-platform-plugin-manager/scripts/validate_progressive_loading.py +31 -0
- package/rainbond-platform-query/SKILL.md +49 -178
- package/rainbond-platform-query/references/generated/runtime-gate.md +109 -0
- package/rainbond-platform-query/references/generated/runtime-routing.md +15 -0
- package/rainbond-project-init/SKILL.md +49 -1132
- package/rainbond-project-init/references/generated/runtime-gate.md +201 -0
- package/rainbond-project-init/references/generated/runtime-routing.md +24 -0
- package/rainbond-project-init/references/generated/user-result.md +16 -0
- package/rainbond-project-init/references/manifest-inference.md +274 -0
- package/rainbond-project-init/references/manifest-rules.md +2 -271
- package/rainbond-project-init/references/operational-reference.md +1 -1
- package/rainbond-project-init/references/output-contract.md +6 -23
- package/rainbond-project-init/references/workflow-and-verification.md +8 -2
- package/rainbond-project-init/schemas/generated/handoff-context.schema.yaml +64 -0
- package/rainbond-project-init/schemas/project-init-result.schema.yaml +122 -0
- package/rainbond-template-installer/SKILL.md +43 -544
- package/rainbond-template-installer/references/generated/runtime-gate.md +201 -0
- package/rainbond-template-installer/references/generated/runtime-routing.md +24 -0
- package/rainbond-template-installer/references/generated/user-result.md +16 -0
- package/rainbond-template-installer/references/output-contract.md +168 -0
- package/rainbond-template-installer/references/template-resolution.md +55 -0
- package/rainbond-template-installer/references/template-workflow.md +129 -0
- package/rainbond-template-installer/schemas/generated/handoff-context.schema.yaml +64 -0
- package/scripts/install-local-cli.mjs +4 -0
- package/scripts/sync-ai-engine-contract.mjs +27 -0
- package/scripts/validate_customer_output.py +21 -14
- package/rainbond-app-assistant/references/runtime-gate.md +0 -178
- package/rainbond-fullstack-bootstrap/modules/40-source-and-package-rules.md +0 -273
- package/rainbond-fullstack-bootstrap/modules/50-workflow-and-convergence.md +0 -220
- package/rainbond-opensource-app-deploy/references/runtime-gate.md +0 -158
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const dns = require("node:dns").promises;
|
|
4
|
+
const http = require("node:http");
|
|
5
|
+
const https = require("node:https");
|
|
6
|
+
const net = require("node:net");
|
|
7
|
+
|
|
8
|
+
const MAX_REDIRECTS = 3;
|
|
9
|
+
const CONNECT_TIMEOUT_MS = 5_000;
|
|
10
|
+
const TOTAL_TIMEOUT_MS = 15_000;
|
|
11
|
+
const MAX_BODY_BYTES = 256 * 1024;
|
|
12
|
+
const MAX_URL_LENGTH = 4_096;
|
|
13
|
+
const POLICY_VERSION = "rainskills.delivery-probe-policy.v1";
|
|
14
|
+
|
|
15
|
+
function isPublicIpv4(address) {
|
|
16
|
+
const parts = address.split(".").map(Number);
|
|
17
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const [a, b] = parts;
|
|
21
|
+
if (a === 0 || a === 10 || a === 127 || a >= 224) return false;
|
|
22
|
+
if (a === 100 && b >= 64 && b <= 127) return false;
|
|
23
|
+
if (a === 169 && b === 254) return false;
|
|
24
|
+
if (a === 172 && b >= 16 && b <= 31) return false;
|
|
25
|
+
if (a === 192 && (b === 0 || b === 168)) return false;
|
|
26
|
+
if (a === 192 && b === 0 && parts[2] === 2) return false;
|
|
27
|
+
if (a === 198 && (b === 18 || b === 19 || b === 51)) return false;
|
|
28
|
+
if (a === 203 && b === 0 && parts[2] === 113) return false;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ipv6Parts(address) {
|
|
33
|
+
let normalized = address.toLowerCase().split("%", 1)[0];
|
|
34
|
+
const dotted = normalized.match(/(\d+\.\d+\.\d+\.\d+)$/);
|
|
35
|
+
if (dotted) {
|
|
36
|
+
if (!net.isIPv4(dotted[1])) return null;
|
|
37
|
+
const octets = dotted[1].split(".").map(Number);
|
|
38
|
+
normalized = `${normalized.slice(0, -dotted[1].length)}${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`;
|
|
39
|
+
}
|
|
40
|
+
const halves = normalized.split("::");
|
|
41
|
+
if (halves.length > 2) return null;
|
|
42
|
+
const left = halves[0] ? halves[0].split(":") : [];
|
|
43
|
+
const right = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
|
44
|
+
const missing = 8 - left.length - right.length;
|
|
45
|
+
if ((halves.length === 1 && missing !== 0) || missing < 0) return null;
|
|
46
|
+
const tokens = [...left, ...Array(missing).fill("0"), ...right];
|
|
47
|
+
if (tokens.length !== 8 || tokens.some((token) => !/^[0-9a-f]{1,4}$/.test(token))) return null;
|
|
48
|
+
return tokens.map((token) => Number.parseInt(token, 16));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function embeddedIpv4(parts, offset) {
|
|
52
|
+
return [
|
|
53
|
+
parts[offset] >> 8,
|
|
54
|
+
parts[offset] & 0xff,
|
|
55
|
+
parts[offset + 1] >> 8,
|
|
56
|
+
parts[offset + 1] & 0xff,
|
|
57
|
+
].join(".");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isPublicIpv6(address) {
|
|
61
|
+
const parts = ipv6Parts(address);
|
|
62
|
+
if (!parts) return false;
|
|
63
|
+
if (parts.every((part) => part === 0)) return false;
|
|
64
|
+
if (parts.slice(0, 7).every((part) => part === 0) && parts[7] === 1) return false;
|
|
65
|
+
if ((parts[0] & 0xfe00) === 0xfc00) return false;
|
|
66
|
+
if ((parts[0] & 0xffc0) === 0xfe80) return false;
|
|
67
|
+
if ((parts[0] & 0xff00) === 0xff00) return false;
|
|
68
|
+
if (parts[0] === 0x0100 && parts.slice(1, 4).every((part) => part === 0)) return false;
|
|
69
|
+
if (parts[0] === 0x2001 && parts[1] === 0x0000) return false;
|
|
70
|
+
if (parts[0] === 0x2001 && (parts[1] & 0xfff0) === 0x0010) return false;
|
|
71
|
+
if (parts[0] === 0x2001 && (parts[1] & 0xfff0) === 0x0020) return false;
|
|
72
|
+
if (parts[0] === 0x2001 && parts[1] === 0x0002) return false;
|
|
73
|
+
if (parts[0] === 0x2001 && parts[1] === 0x0db8) return false;
|
|
74
|
+
if ((parts[0] & 0xfff0) === 0x3ff0) return false;
|
|
75
|
+
if (parts.slice(0, 5).every((part) => part === 0) && parts[5] === 0xffff) {
|
|
76
|
+
return isPublicIpv4(embeddedIpv4(parts, 6));
|
|
77
|
+
}
|
|
78
|
+
if (parts.slice(0, 6).every((part) => part === 0)) {
|
|
79
|
+
return isPublicIpv4(embeddedIpv4(parts, 6));
|
|
80
|
+
}
|
|
81
|
+
if (parts[0] === 0x0064 && parts[1] === 0xff9b && parts.slice(2, 6).every((part) => part === 0)) {
|
|
82
|
+
return isPublicIpv4(embeddedIpv4(parts, 6));
|
|
83
|
+
}
|
|
84
|
+
if (parts[0] === 0x2002) return isPublicIpv4(embeddedIpv4(parts, 1));
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isPublicAddress(address) {
|
|
89
|
+
const family = net.isIP(address);
|
|
90
|
+
if (family === 4) return isPublicIpv4(address);
|
|
91
|
+
if (family === 6) return isPublicIpv6(address);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizedHostname(url) {
|
|
96
|
+
return url.hostname.replace(/^\[|\]$/g, "");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function defaultResolveHost(hostname) {
|
|
100
|
+
const records = await dns.lookup(hostname, { all: true, verbatim: true });
|
|
101
|
+
return records.map((record) => record.address);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function defaultRequestUrl(url, {
|
|
105
|
+
method,
|
|
106
|
+
address,
|
|
107
|
+
headers,
|
|
108
|
+
connectTimeoutMs = CONNECT_TIMEOUT_MS,
|
|
109
|
+
totalTimeoutMs = TOTAL_TIMEOUT_MS,
|
|
110
|
+
maxBodyBytes = MAX_BODY_BYTES,
|
|
111
|
+
}) {
|
|
112
|
+
const client = url.protocol === "https:" ? https : http;
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
let settled = false;
|
|
115
|
+
let connectTimer;
|
|
116
|
+
let totalTimer;
|
|
117
|
+
const finish = (result, request) => {
|
|
118
|
+
if (settled) return;
|
|
119
|
+
settled = true;
|
|
120
|
+
clearTimeout(connectTimer);
|
|
121
|
+
clearTimeout(totalTimer);
|
|
122
|
+
if (request && !request.destroyed) request.destroy();
|
|
123
|
+
resolve(result);
|
|
124
|
+
};
|
|
125
|
+
const request = client.request(url, {
|
|
126
|
+
method,
|
|
127
|
+
headers,
|
|
128
|
+
redirect: "manual",
|
|
129
|
+
lookup: (_hostname, options, callback) => {
|
|
130
|
+
const family = net.isIP(address);
|
|
131
|
+
if (options && options.all) {
|
|
132
|
+
callback(null, [{ address, family }]);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
callback(null, address, family);
|
|
136
|
+
},
|
|
137
|
+
}, (response) => {
|
|
138
|
+
const statusCode = response.statusCode || 0;
|
|
139
|
+
const location = response.headers.location;
|
|
140
|
+
const contentType = String(response.headers["content-type"] || "");
|
|
141
|
+
const contentLength = Number(response.headers["content-length"] || 0);
|
|
142
|
+
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
|
|
143
|
+
response.resume();
|
|
144
|
+
finish({ error: "body_too_large" }, request);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (method === "HEAD" || (statusCode >= 300 && statusCode < 400)) {
|
|
148
|
+
response.resume();
|
|
149
|
+
finish({ statusCode, location, contentType, bodyBytes: 0 }, request);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let bodyBytes = 0;
|
|
153
|
+
response.on("data", (chunk) => {
|
|
154
|
+
bodyBytes += chunk.length;
|
|
155
|
+
if (bodyBytes > maxBodyBytes) finish({ error: "body_too_large" }, request);
|
|
156
|
+
});
|
|
157
|
+
response.on("end", () => finish({ statusCode, location, contentType, bodyBytes }, request));
|
|
158
|
+
response.on("error", () => finish({ error: "response_error" }, request));
|
|
159
|
+
});
|
|
160
|
+
request.on("socket", (socket) => {
|
|
161
|
+
if (!socket.connecting) return;
|
|
162
|
+
connectTimer = setTimeout(() => finish({ error: "timeout" }, request), connectTimeoutMs);
|
|
163
|
+
socket.once(url.protocol === "https:" ? "secureConnect" : "connect", () => clearTimeout(connectTimer));
|
|
164
|
+
});
|
|
165
|
+
request.on("error", (error) => finish({ error: error.code === "ETIMEDOUT" ? "timeout" : "request_error" }, request));
|
|
166
|
+
totalTimer = setTimeout(() => finish({ error: "timeout" }, request), totalTimeoutMs);
|
|
167
|
+
request.end();
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseTarget(value) {
|
|
172
|
+
if (typeof value !== "string" || !value || value.length > MAX_URL_LENGTH) return null;
|
|
173
|
+
let url;
|
|
174
|
+
try {
|
|
175
|
+
url = new URL(value);
|
|
176
|
+
} catch (_error) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) return null;
|
|
180
|
+
return url;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function createDeliveryProbe({
|
|
184
|
+
resolveHost = defaultResolveHost,
|
|
185
|
+
requestUrl = defaultRequestUrl,
|
|
186
|
+
} = {}) {
|
|
187
|
+
return async function probe(input) {
|
|
188
|
+
const allowedInputKeys = new Set(["policy_version", "url", "method"]);
|
|
189
|
+
const validShape = input && typeof input === "object" && !Array.isArray(input)
|
|
190
|
+
&& Object.keys(input).every((key) => allowedInputKeys.has(key));
|
|
191
|
+
const method = input?.method;
|
|
192
|
+
let current = parseTarget(input?.url);
|
|
193
|
+
const candidateUrl = current ? current.href : String(input?.url || "");
|
|
194
|
+
if (!validShape || !current || !["HEAD", "GET"].includes(method) || input?.policy_version !== POLICY_VERSION) {
|
|
195
|
+
return { policy_version: POLICY_VERSION, status: "rejected", reason: "unsafe_url_or_method", candidate_url: candidateUrl };
|
|
196
|
+
}
|
|
197
|
+
const redirects = [];
|
|
198
|
+
let prefetchedResolution = null;
|
|
199
|
+
|
|
200
|
+
const resolveSafeAddresses = async (url) => {
|
|
201
|
+
const hostname = normalizedHostname(url);
|
|
202
|
+
try {
|
|
203
|
+
const addresses = net.isIP(hostname) ? [hostname] : await resolveHost(hostname);
|
|
204
|
+
if (!Array.isArray(addresses) || !addresses.length || addresses.some((address) => !isPublicAddress(address))) {
|
|
205
|
+
return { error: "non_public_address" };
|
|
206
|
+
}
|
|
207
|
+
return { addresses };
|
|
208
|
+
} catch (_error) {
|
|
209
|
+
return { error: "dns_error" };
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
|
|
214
|
+
const resolved = prefetchedResolution || await resolveSafeAddresses(current);
|
|
215
|
+
prefetchedResolution = null;
|
|
216
|
+
if (resolved.error) {
|
|
217
|
+
return {
|
|
218
|
+
policy_version: POLICY_VERSION,
|
|
219
|
+
status: resolved.error === "dns_error" ? "failed" : "rejected",
|
|
220
|
+
reason: resolved.error,
|
|
221
|
+
candidate_url: candidateUrl,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const headers = Object.freeze({ Accept: "*/*", "User-Agent": "rainskills-delivery-probe/1" });
|
|
226
|
+
const response = await requestUrl(current, {
|
|
227
|
+
method,
|
|
228
|
+
address: resolved.addresses[0],
|
|
229
|
+
headers,
|
|
230
|
+
connectTimeoutMs: CONNECT_TIMEOUT_MS,
|
|
231
|
+
totalTimeoutMs: TOTAL_TIMEOUT_MS,
|
|
232
|
+
maxBodyBytes: MAX_BODY_BYTES,
|
|
233
|
+
});
|
|
234
|
+
if (response?.error) {
|
|
235
|
+
return { policy_version: POLICY_VERSION, status: "failed", reason: response.error, candidate_url: candidateUrl };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const statusCode = Number(response?.statusCode || 0);
|
|
239
|
+
if (statusCode >= 300 && statusCode < 400) {
|
|
240
|
+
if (!response.location) {
|
|
241
|
+
return { policy_version: POLICY_VERSION, status: "failed", reason: "redirect_without_location", candidate_url: candidateUrl };
|
|
242
|
+
}
|
|
243
|
+
if (redirectCount === MAX_REDIRECTS) {
|
|
244
|
+
return { policy_version: POLICY_VERSION, status: "failed", reason: "too_many_redirects", candidate_url: candidateUrl };
|
|
245
|
+
}
|
|
246
|
+
let next;
|
|
247
|
+
try {
|
|
248
|
+
next = parseTarget(new URL(response.location, current).href);
|
|
249
|
+
} catch (_error) {
|
|
250
|
+
next = null;
|
|
251
|
+
}
|
|
252
|
+
if (!next) return { policy_version: POLICY_VERSION, status: "rejected", reason: "unsafe_redirect", candidate_url: candidateUrl };
|
|
253
|
+
const nextResolved = await resolveSafeAddresses(next);
|
|
254
|
+
if (nextResolved.error) {
|
|
255
|
+
return { policy_version: POLICY_VERSION, status: "rejected", reason: "unsafe_redirect", candidate_url: candidateUrl };
|
|
256
|
+
}
|
|
257
|
+
if (current.protocol === "https:" && next.protocol !== "https:") {
|
|
258
|
+
return { policy_version: POLICY_VERSION, status: "rejected", reason: "redirect_downgrade", candidate_url: candidateUrl };
|
|
259
|
+
}
|
|
260
|
+
const sameEndpoint = next.host === current.host && next.protocol === current.protocol;
|
|
261
|
+
const httpUpgrade = current.protocol === "http:"
|
|
262
|
+
&& next.protocol === "https:"
|
|
263
|
+
&& normalizedHostname(next) === normalizedHostname(current)
|
|
264
|
+
&& (current.port || "80") === "80"
|
|
265
|
+
&& (next.port || "443") === "443";
|
|
266
|
+
if (!sameEndpoint && !httpUpgrade) {
|
|
267
|
+
return {
|
|
268
|
+
policy_version: POLICY_VERSION,
|
|
269
|
+
status: "manual_validation_required",
|
|
270
|
+
reason: "cross_host_redirect",
|
|
271
|
+
candidate_url: candidateUrl,
|
|
272
|
+
redirect_target: next.href,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
redirects.push(next.href);
|
|
276
|
+
current = next;
|
|
277
|
+
prefetchedResolution = nextResolved;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
policy_version: POLICY_VERSION,
|
|
283
|
+
status: statusCode >= 200 && statusCode < 400 ? "verified" : "failed",
|
|
284
|
+
reason: statusCode >= 200 && statusCode < 400 ? null : "http_status",
|
|
285
|
+
candidate_url: candidateUrl,
|
|
286
|
+
final_url: current.href,
|
|
287
|
+
status_code: statusCode,
|
|
288
|
+
content_type: String(response.contentType || ""),
|
|
289
|
+
body_bytes: Number(response.bodyBytes || 0),
|
|
290
|
+
redirects,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return { policy_version: POLICY_VERSION, status: "failed", reason: "too_many_redirects", candidate_url: candidateUrl };
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = {
|
|
298
|
+
CONNECT_TIMEOUT_MS,
|
|
299
|
+
MAX_BODY_BYTES,
|
|
300
|
+
MAX_REDIRECTS,
|
|
301
|
+
TOTAL_TIMEOUT_MS,
|
|
302
|
+
POLICY_VERSION,
|
|
303
|
+
createDeliveryProbe,
|
|
304
|
+
defaultRequestUrl,
|
|
305
|
+
isPublicAddress,
|
|
306
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { createHash } = require("node:crypto");
|
|
4
|
+
|
|
5
|
+
const SCHEMA = "rainskills.handoff-context.v1";
|
|
6
|
+
const PROFILE = new Set(["cli", "embedded"]);
|
|
7
|
+
const SOURCE_KIND = new Set(["source", "image", "package", "template", null]);
|
|
8
|
+
const RUNTIME_STATE = new Set([
|
|
9
|
+
"topology_missing", "topology_building", "runtime_unhealthy", "runtime_healthy",
|
|
10
|
+
"capacity_blocked", "code_or_build_handoff_needed",
|
|
11
|
+
]);
|
|
12
|
+
const NEXT_PHASE = new Set(["init", "bootstrap", "troubleshoot", "verify", "stop"]);
|
|
13
|
+
const CONTEXT_KEYS = Object.freeze([
|
|
14
|
+
"schema", "producer", "profile", "observed_at", "enterprise_id", "team_id",
|
|
15
|
+
"team_name", "region_name", "app_id", "app_name", "runtime_fingerprint",
|
|
16
|
+
"identity_fingerprint", "snapshot_revision", "source_kind", "runtime_state",
|
|
17
|
+
"next_phase", "invalidation_reason",
|
|
18
|
+
]);
|
|
19
|
+
const CREATE_KEYS = new Set([
|
|
20
|
+
"producer", "profile", "observed_at", "enterprise_id", "team_id", "team_name",
|
|
21
|
+
"region_name", "app_id", "app_name", "endpoint", "runtime_revision",
|
|
22
|
+
"snapshot_revision", "source_kind", "runtime_state", "next_phase", "invalidation_reason",
|
|
23
|
+
]);
|
|
24
|
+
const SENSITIVE_KEY = /(?:jwt|token|password|secret|credential|confirmation|raw.?log|tool.?output)/i;
|
|
25
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
26
|
+
const SKILL_ID = /^rainbond-[a-z0-9-]+$/;
|
|
27
|
+
|
|
28
|
+
function canonical(value) {
|
|
29
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
30
|
+
if (value && typeof value === "object") {
|
|
31
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fingerprint(value) {
|
|
37
|
+
return createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assertString(value, label, nullable = false) {
|
|
41
|
+
if (nullable && value === null) return;
|
|
42
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function runtimeIdentity(value) {
|
|
46
|
+
return {
|
|
47
|
+
profile: value.profile,
|
|
48
|
+
endpoint: value.endpoint,
|
|
49
|
+
runtime_revision: value.runtime_revision,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function workspaceIdentity(value) {
|
|
54
|
+
return {
|
|
55
|
+
enterprise_id: value.enterprise_id,
|
|
56
|
+
team_id: value.team_id,
|
|
57
|
+
region_name: value.region_name,
|
|
58
|
+
app_id: value.app_id,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validateCreationInput(value) {
|
|
63
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("handoff input must be an object");
|
|
64
|
+
for (const key of Object.keys(value)) {
|
|
65
|
+
if (SENSITIVE_KEY.test(key)) throw new Error("sensitive handoff fields are forbidden");
|
|
66
|
+
if (!CREATE_KEYS.has(key)) throw new Error(`unknown handoff field: ${key}`);
|
|
67
|
+
}
|
|
68
|
+
if (!SKILL_ID.test(value.producer || "")) throw new Error("producer is invalid");
|
|
69
|
+
if (!PROFILE.has(value.profile)) throw new Error("profile is invalid");
|
|
70
|
+
if (!Number.isFinite(Date.parse(value.observed_at))) throw new Error("observed_at is invalid");
|
|
71
|
+
for (const key of ["enterprise_id", "team_id", "team_name", "region_name", "endpoint", "runtime_revision"]) assertString(value[key], key);
|
|
72
|
+
if (!(value.app_id === null || (Number.isInteger(value.app_id) && value.app_id > 0))) throw new Error("app_id is invalid");
|
|
73
|
+
assertString(value.app_name, "app_name", true);
|
|
74
|
+
assertString(value.snapshot_revision, "snapshot_revision", true);
|
|
75
|
+
if (!SOURCE_KIND.has(value.source_kind)) throw new Error("source_kind is invalid");
|
|
76
|
+
if (!RUNTIME_STATE.has(value.runtime_state)) throw new Error("runtime_state is invalid");
|
|
77
|
+
if (!NEXT_PHASE.has(value.next_phase)) throw new Error("next_phase is invalid");
|
|
78
|
+
assertString(value.invalidation_reason, "invalidation_reason", true);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function validateContextShape(context) {
|
|
82
|
+
if (!context || typeof context !== "object" || Array.isArray(context)) throw new Error("context must be an object");
|
|
83
|
+
const keys = Object.keys(context).sort();
|
|
84
|
+
if (JSON.stringify(keys) !== JSON.stringify([...CONTEXT_KEYS].sort())) throw new Error("context fields are invalid");
|
|
85
|
+
if (context.schema !== SCHEMA || !SKILL_ID.test(context.producer || "") || !PROFILE.has(context.profile)) throw new Error("context identity is invalid");
|
|
86
|
+
if (!Number.isFinite(Date.parse(context.observed_at))) throw new Error("context observed_at is invalid");
|
|
87
|
+
for (const key of ["enterprise_id", "team_id", "team_name", "region_name"]) assertString(context[key], key);
|
|
88
|
+
if (!(context.app_id === null || (Number.isInteger(context.app_id) && context.app_id > 0))) throw new Error("context app_id is invalid");
|
|
89
|
+
assertString(context.app_name, "app_name", true);
|
|
90
|
+
if (!SHA256.test(context.runtime_fingerprint) || !SHA256.test(context.identity_fingerprint)) throw new Error("context fingerprint is invalid");
|
|
91
|
+
assertString(context.snapshot_revision, "snapshot_revision", true);
|
|
92
|
+
if (!SOURCE_KIND.has(context.source_kind) || !RUNTIME_STATE.has(context.runtime_state) || !NEXT_PHASE.has(context.next_phase)) throw new Error("context state is invalid");
|
|
93
|
+
assertString(context.invalidation_reason, "invalidation_reason", true);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function createHandoffContext(value) {
|
|
97
|
+
validateCreationInput(value);
|
|
98
|
+
return {
|
|
99
|
+
schema: SCHEMA,
|
|
100
|
+
producer: value.producer,
|
|
101
|
+
profile: value.profile,
|
|
102
|
+
observed_at: new Date(value.observed_at).toISOString(),
|
|
103
|
+
enterprise_id: value.enterprise_id,
|
|
104
|
+
team_id: value.team_id,
|
|
105
|
+
team_name: value.team_name,
|
|
106
|
+
region_name: value.region_name,
|
|
107
|
+
app_id: value.app_id,
|
|
108
|
+
app_name: value.app_name,
|
|
109
|
+
runtime_fingerprint: fingerprint(runtimeIdentity(value)),
|
|
110
|
+
identity_fingerprint: fingerprint(workspaceIdentity(value)),
|
|
111
|
+
snapshot_revision: value.snapshot_revision,
|
|
112
|
+
source_kind: value.source_kind,
|
|
113
|
+
runtime_state: value.runtime_state,
|
|
114
|
+
next_phase: value.next_phase,
|
|
115
|
+
invalidation_reason: value.invalidation_reason,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function validateHandoffContext(context, { current, signal = null, now = Date.now(), maxAgeMs = 30_000 } = {}) {
|
|
120
|
+
try {
|
|
121
|
+
validateContextShape(context);
|
|
122
|
+
validateCreationInput({
|
|
123
|
+
producer: context.producer,
|
|
124
|
+
observed_at: context.observed_at,
|
|
125
|
+
team_name: context.team_name,
|
|
126
|
+
app_name: context.app_name,
|
|
127
|
+
snapshot_revision: context.snapshot_revision,
|
|
128
|
+
source_kind: context.source_kind,
|
|
129
|
+
runtime_state: context.runtime_state,
|
|
130
|
+
next_phase: context.next_phase,
|
|
131
|
+
invalidation_reason: context.invalidation_reason,
|
|
132
|
+
...current,
|
|
133
|
+
});
|
|
134
|
+
} catch (_error) {
|
|
135
|
+
return { valid: false, reason: "invalid-context" };
|
|
136
|
+
}
|
|
137
|
+
const signalReasons = new Set(["runtime-reconnect", "unauthorized-401", "forbidden-403", "not-found", "revision-conflict"]);
|
|
138
|
+
if (signal && signalReasons.has(signal)) return { valid: false, reason: signal };
|
|
139
|
+
if (fingerprint(runtimeIdentity(current)) !== context.runtime_fingerprint) return { valid: false, reason: "runtime-identity-changed" };
|
|
140
|
+
if (fingerprint(workspaceIdentity(current)) !== context.identity_fingerprint) return { valid: false, reason: "workspace-identity-changed" };
|
|
141
|
+
if (current.source_kind !== context.source_kind) return { valid: false, reason: "source-strategy-changed" };
|
|
142
|
+
if (current.snapshot_revision !== context.snapshot_revision) return { valid: false, reason: "snapshot-revision-changed" };
|
|
143
|
+
if (now - Date.parse(context.observed_at) > maxAgeMs) return { valid: false, reason: "mutable-state-stale" };
|
|
144
|
+
if (context.invalidation_reason) return { valid: false, reason: context.invalidation_reason };
|
|
145
|
+
return { valid: true, reason: null };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
createHandoffContext,
|
|
150
|
+
validateHandoffContext,
|
|
151
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const READ_PREFIXES = [
|
|
4
|
+
"rainbond_query_", "rainbond_get_", "rainbond_list_", "rainbond_check_",
|
|
5
|
+
"rainbond_describe_", "rainbond_validate_", "rainbond_verify_", "rainbond_search_",
|
|
6
|
+
];
|
|
7
|
+
const DESTRUCTIVE = /(?:delete|remove|purge|destroy|update|create|set|manage|operate|restart|deploy)/i;
|
|
8
|
+
const STATUS_PATH = /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*){0,3}$/;
|
|
9
|
+
|
|
10
|
+
function plain(value) {
|
|
11
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readOnly(name) {
|
|
15
|
+
return typeof name === "string"
|
|
16
|
+
&& READ_PREFIXES.some((prefix) => name.startsWith(prefix))
|
|
17
|
+
&& !DESTRUCTIVE.test(name);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function stringArray(value, label) {
|
|
21
|
+
if (!Array.isArray(value) || value.length < 1 || value.some((item) => typeof item !== "string" || !item)) {
|
|
22
|
+
throw new Error(`${label} must be a non-empty string array`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function validatePollInput(toolName, input) {
|
|
27
|
+
if (!readOnly(toolName)) throw new Error("poll accepts only a read-only tool");
|
|
28
|
+
if (!plain(input) || !plain(input.arguments)) throw new Error("poll input and arguments must be objects");
|
|
29
|
+
if (!STATUS_PATH.test(input.status_path || "") || input.status_path.includes("__proto__")) throw new Error("status_path is invalid");
|
|
30
|
+
stringArray(input.terminal_values, "terminal_values");
|
|
31
|
+
stringArray(input.failure_values, "failure_values");
|
|
32
|
+
if (input.retryable_values !== undefined) stringArray(input.retryable_values, "retryable_values");
|
|
33
|
+
if (!Number.isInteger(input.max_attempts) || input.max_attempts < 1 || input.max_attempts > 12) throw new Error("max_attempts must be between 1 and 12");
|
|
34
|
+
if (!Number.isInteger(input.interval_ms) || input.interval_ms < 0 || input.interval_ms > 10_000) throw new Error("interval_ms is invalid");
|
|
35
|
+
if (!Number.isInteger(input.timeout_ms) || input.timeout_ms < 1 || input.timeout_ms > 60_000) throw new Error("timeout_ms is invalid");
|
|
36
|
+
return input;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function valueAtPath(value, path) {
|
|
40
|
+
let current = value;
|
|
41
|
+
for (const segment of path.split(".")) {
|
|
42
|
+
if (!plain(current) || !Object.hasOwn(current, segment)) return undefined;
|
|
43
|
+
current = current[segment];
|
|
44
|
+
}
|
|
45
|
+
return current;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function result({ transitions, attempts, unchanged, finalState, blocker, retryable, start, end }) {
|
|
49
|
+
return {
|
|
50
|
+
schema: "rainskills.protected-poll.v1",
|
|
51
|
+
transitions,
|
|
52
|
+
final_state: finalState,
|
|
53
|
+
completed: blocker === null,
|
|
54
|
+
blocker,
|
|
55
|
+
retryable,
|
|
56
|
+
attempts,
|
|
57
|
+
unchanged_omitted: unchanged,
|
|
58
|
+
elapsed_ms: Math.max(0, end - start),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function executeProtectedPoll(toolName, input, {
|
|
63
|
+
callTool,
|
|
64
|
+
sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
65
|
+
now = () => Date.now(),
|
|
66
|
+
signal,
|
|
67
|
+
} = {}) {
|
|
68
|
+
validatePollInput(toolName, input);
|
|
69
|
+
if (typeof callTool !== "function") throw new Error("poll callTool is required");
|
|
70
|
+
const start = now();
|
|
71
|
+
const transitions = [];
|
|
72
|
+
let previous;
|
|
73
|
+
let attempts = 0;
|
|
74
|
+
let unchanged = 0;
|
|
75
|
+
for (; attempts < input.max_attempts; attempts += 1) {
|
|
76
|
+
if (signal?.aborted) return result({ transitions, attempts, unchanged, finalState: previous ?? null, blocker: "cancelled", retryable: false, start, end: now() });
|
|
77
|
+
if (now() - start > input.timeout_ms) return result({ transitions, attempts, unchanged, finalState: previous ?? null, blocker: "timeout", retryable: true, start, end: now() });
|
|
78
|
+
let response;
|
|
79
|
+
try {
|
|
80
|
+
response = await callTool(toolName, input.arguments);
|
|
81
|
+
} catch (_error) {
|
|
82
|
+
return result({ transitions, attempts: attempts + 1, unchanged, finalState: previous ?? null, blocker: "read-failed", retryable: true, start, end: now() });
|
|
83
|
+
}
|
|
84
|
+
const state = valueAtPath(response, input.status_path);
|
|
85
|
+
if (typeof state !== "string" && typeof state !== "number" && typeof state !== "boolean") {
|
|
86
|
+
return result({ transitions, attempts: attempts + 1, unchanged, finalState: previous ?? null, blocker: "status-unavailable", retryable: false, start, end: now() });
|
|
87
|
+
}
|
|
88
|
+
const normalized = String(state);
|
|
89
|
+
if (normalized !== previous) {
|
|
90
|
+
transitions.push({ state: normalized, attempt: attempts + 1 });
|
|
91
|
+
previous = normalized;
|
|
92
|
+
} else {
|
|
93
|
+
unchanged += 1;
|
|
94
|
+
}
|
|
95
|
+
if (input.terminal_values.includes(normalized)) {
|
|
96
|
+
const failed = input.failure_values.includes(normalized);
|
|
97
|
+
return result({
|
|
98
|
+
transitions,
|
|
99
|
+
attempts: attempts + 1,
|
|
100
|
+
unchanged,
|
|
101
|
+
finalState: normalized,
|
|
102
|
+
blocker: failed ? String(response.blocker || "terminal-failure") : null,
|
|
103
|
+
retryable: failed && (input.retryable_values || []).includes(normalized),
|
|
104
|
+
start,
|
|
105
|
+
end: now(),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (attempts + 1 < input.max_attempts && input.interval_ms > 0) await sleep(input.interval_ms);
|
|
109
|
+
}
|
|
110
|
+
return result({ transitions, attempts, unchanged, finalState: previous ?? null, blocker: "attempt-budget-exhausted", retryable: true, start, end: now() });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
executeProtectedPoll,
|
|
115
|
+
validatePollInput,
|
|
116
|
+
};
|